The update check has been daily at 03:00, Persistent, since 0.1.5 — the lab's installed stable machine logged it at 03:01 and 03:04 on successive days — and it stays so. What changes is the reboot after an update. It happened the moment the update was installed: harmless at 03:00, and a room restarting itself mid-morning when a computer switched off at 03:00 caught up after being switched on. Now the updater installs, with every check it had (the device's own channel, a newer version, the manifest's SHA-256, name and version), and starts classroom-reboot-when-idle, which reboots once no input device has produced an event for 30 minutes and no sound card has played for as long. At 03:00 that is about 03:30; switched off first, the next start is on the new version. The service has no [Install] and nothing enables it. The release gate checks after every install and upgrade that the timer is enabled, daily at 03:00, Persistent, with no boot or repeat trigger and no drop-in, and that the updater no longer reboots by itself. Co-Authored-By: Claude Opus 5.5 <[email protected]>
307 lines
8.8 KiB
Python
Executable File
307 lines
8.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import argparse
|
|
import fcntl
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import urllib.request
|
|
from urllib.parse import urljoin
|
|
|
|
CONFIG = "/etc/ekklesia-classroom/device.conf"
|
|
LOCKFILE = "/run/ekklesia-classroom-update.lock"
|
|
REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"
|
|
CACHE_DIR = "/var/cache/ekklesia-classroom"
|
|
PACKAGE = "ekklesia-classroom"
|
|
|
|
# What a published version looks like: 0.2.6 for a feature release,
|
|
# 0.2.6.1 for a maintenance release of it. Anything else in a manifest is
|
|
# a mistake on the server, and a classroom does nothing with it. The same
|
|
# pattern is in tools/release-lib.sh; tests/test-release-versions.sh holds
|
|
# the two to one list of good and bad versions.
|
|
#
|
|
# Only the SHAPE is decided here. Which of two versions is newer is always
|
|
# dpkg's answer, because dpkg is what installs them.
|
|
VERSION_PATTERN = re.compile(
|
|
r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\.[1-9][0-9]*)?$"
|
|
)
|
|
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
|
|
|
|
|
def log(message):
|
|
print(f"[ekklesia-classroom-update] {message}", flush=True)
|
|
|
|
|
|
def read_config():
|
|
config = {
|
|
"UPDATE_URL": "https://updates.riverofglorymn.org",
|
|
"CHANNEL": "stable",
|
|
}
|
|
|
|
if os.path.exists(CONFIG):
|
|
with open(CONFIG, "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
config[key.strip()] = value.strip()
|
|
|
|
return config
|
|
|
|
|
|
def installed_version():
|
|
result = subprocess.run(
|
|
["dpkg-query", "-W", "-f=${Version}", "ekklesia-classroom"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
return "0.0.0"
|
|
|
|
return result.stdout.strip()
|
|
|
|
|
|
def valid_version(version):
|
|
return isinstance(version, str) and bool(VERSION_PATTERN.match(version))
|
|
|
|
|
|
def version_is_newer(candidate, current):
|
|
result = subprocess.run(
|
|
["dpkg", "--compare-versions", candidate, "gt", current],
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
return result.returncode == 0
|
|
|
|
|
|
def manifest_problem(manifest):
|
|
"""Why this manifest must not be acted on, or None if it may be.
|
|
|
|
A manifest names a version, the package file for exactly that
|
|
version, and that file's checksum. The package name is checked
|
|
against the version, not merely read, so a manifest that says 0.2.6.1
|
|
and points at the 0.2.6 file is refused rather than installed and
|
|
then offered again at every check.
|
|
"""
|
|
if not isinstance(manifest, dict):
|
|
return "the manifest is not an object"
|
|
|
|
version = manifest.get("version")
|
|
|
|
if not valid_version(version):
|
|
return f"the manifest names an invalid version: {version!r}"
|
|
|
|
package = manifest.get("package")
|
|
expected = f"/packages/{PACKAGE}_{version}_amd64.deb"
|
|
|
|
if package != expected:
|
|
return f"the manifest names {package!r} for version {version}"
|
|
|
|
checksum = manifest.get("sha256")
|
|
|
|
if not isinstance(checksum, str) or not SHA256_PATTERN.match(checksum.lower()):
|
|
return f"the manifest's sha256 is not a checksum: {checksum!r}"
|
|
|
|
return None
|
|
|
|
|
|
def package_identity(path):
|
|
"""(Package, Version) as dpkg reads them out of a downloaded file."""
|
|
result = subprocess.run(
|
|
["dpkg-deb", "--field", path, "Package", "Version"],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
return None, None
|
|
|
|
fields = {}
|
|
|
|
for line in result.stdout.splitlines():
|
|
if ":" in line:
|
|
key, value = line.split(":", 1)
|
|
fields[key.strip()] = value.strip()
|
|
|
|
return fields.get("Package"), fields.get("Version")
|
|
|
|
|
|
def fetch_json(url):
|
|
request = urllib.request.Request(
|
|
url,
|
|
headers={"User-Agent": "Ekklesia-Classroom-Updater/0.1"},
|
|
)
|
|
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
return json.load(response)
|
|
|
|
|
|
def download(url, destination):
|
|
request = urllib.request.Request(
|
|
url,
|
|
headers={"User-Agent": "Ekklesia-Classroom-Updater/0.1"},
|
|
)
|
|
|
|
with urllib.request.urlopen(request, timeout=120) as response:
|
|
with open(destination, "wb") as f:
|
|
while True:
|
|
chunk = response.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
f.write(chunk)
|
|
|
|
|
|
def sha256(path):
|
|
digest = hashlib.sha256()
|
|
|
|
with open(path, "rb") as f:
|
|
while True:
|
|
chunk = f.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
digest.update(chunk)
|
|
|
|
return digest.hexdigest()
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"--no-reboot",
|
|
action="store_true",
|
|
help="Install the update but do not reboot automatically",
|
|
)
|
|
parser.add_argument(
|
|
"--check",
|
|
action="store_true",
|
|
help="Check for an update without installing it",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
with open(LOCKFILE, "w", encoding="utf-8") as lock:
|
|
try:
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
log("Another update is already running.")
|
|
return 0
|
|
|
|
config = read_config()
|
|
|
|
base_url = config["UPDATE_URL"].rstrip("/") + "/"
|
|
channel = config["CHANNEL"]
|
|
manifest_url = urljoin(base_url, f"{channel}/manifest.json")
|
|
|
|
current = installed_version()
|
|
|
|
log(f"Current version: {current}")
|
|
log(f"Channel: {channel}")
|
|
log(f"Checking: {manifest_url}")
|
|
|
|
try:
|
|
manifest = fetch_json(manifest_url)
|
|
except Exception as exc:
|
|
log(f"Unable to fetch update manifest: {exc}")
|
|
return 1
|
|
|
|
problem = manifest_problem(manifest)
|
|
|
|
if problem:
|
|
log(f"Ignoring the {channel} manifest: {problem}.")
|
|
return 1
|
|
|
|
candidate = manifest["version"]
|
|
|
|
log(f"Available version: {candidate}")
|
|
|
|
if not version_is_newer(candidate, current):
|
|
log("Already up to date.")
|
|
return 0
|
|
|
|
if args.check:
|
|
log(f"Update available: {current} -> {candidate}")
|
|
return 0
|
|
|
|
package_url = urljoin(base_url, manifest["package"].lstrip("/"))
|
|
expected_hash = manifest["sha256"].lower()
|
|
|
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
|
|
package_path = os.path.join(
|
|
CACHE_DIR,
|
|
os.path.basename(manifest["package"]),
|
|
)
|
|
|
|
temporary_path = package_path + ".download"
|
|
|
|
log(f"Downloading {package_url}")
|
|
|
|
try:
|
|
download(package_url, temporary_path)
|
|
except Exception as exc:
|
|
log(f"Download failed: {exc}")
|
|
return 1
|
|
|
|
actual_hash = sha256(temporary_path)
|
|
|
|
if actual_hash != expected_hash:
|
|
log("SHA-256 verification FAILED.")
|
|
log(f"Expected: {expected_hash}")
|
|
log(f"Received: {actual_hash}")
|
|
os.unlink(temporary_path)
|
|
return 1
|
|
|
|
log("SHA-256 verification passed.")
|
|
|
|
# The checksum proves the file is the one the manifest describes;
|
|
# this proves the manifest described the right file. A package
|
|
# that installs as some other version would be offered again at
|
|
# every check, and installed again, and the room rebooted, for
|
|
# ever.
|
|
name, version = package_identity(temporary_path)
|
|
|
|
if name != PACKAGE or version != candidate:
|
|
log(f"The package is {name} {version}, not {PACKAGE} {candidate}.")
|
|
os.unlink(temporary_path)
|
|
return 1
|
|
|
|
os.replace(temporary_path, package_path)
|
|
|
|
log(f"Installing {candidate}")
|
|
|
|
result = subprocess.run(
|
|
[
|
|
"apt-get",
|
|
"install",
|
|
"-y",
|
|
package_path,
|
|
]
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
log("Package installation failed.")
|
|
return result.returncode
|
|
|
|
log(f"Update to {candidate} installed successfully.")
|
|
|
|
if manifest.get("requires_reboot", False):
|
|
if args.no_reboot:
|
|
log("Update requires reboot, but --no-reboot was specified.")
|
|
else:
|
|
# Not now. At 03:00 nobody is there; after a missed 03:00,
|
|
# checked when the computer is next switched on, somebody
|
|
# usually is. classroom-reboot-when-idle waits until nobody
|
|
# has touched it for half an hour and nothing is playing.
|
|
log("Update requires reboot. Rebooting once nobody is using this computer.")
|
|
subprocess.run(["systemctl", "start", "--no-block", REBOOT_WHEN_IDLE])
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|