Files
Classroom_Desktop/scripts/ekklesia-classroom-update
T
ekklesiaandClaude Opus 5.5 690ef95aff feat: four-part versions, and a published version is never used again
0.2.6.1 is a maintenance release of 0.2.6. The build refused four
parts, update_canary.sh's automatic bump could not add one to "6.1", and
neither publish script would have noticed a version being published
twice — which is what happened to 0.2.6 this morning: it was rebuilt
with the Presentations hardening and uploaded over the first 0.2.6, and
a room that had installed the first can never be offered the second.

One release library now holds the version pattern (three or four parts,
no leading zeros, no .0 maintenance), orders by dpkg --compare-versions,
refuses a version that has ever been published or is not newer than all
of them, uploads with --ignore-existing and checks the server's
checksum, writes a manifest only for a package the server holds, and
reads the channel back over HTTPS afterwards. update_canary.sh takes the
version explicitly; update_stable.sh refuses to go backwards.

The classroom updater refuses a manifest whose version, package name or
checksum is malformed, and refuses a download whose own control file is
not the version the manifest promised, so a room can never loop on
reinstalling and rebooting.

tests/test-release-versions.sh: 87 checks against a stand-in server and
real dpkg. The Trixie gate now also rehearses a stable room: 0.2.2 to an
interim 0.2.6.1 upgraded with settings, channel and Presentations=false
intact, and the installed updater answering correctly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-23 18:19:37 -05:00

302 lines
8.4 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"
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:
log("Update requires reboot. Rebooting now.")
subprocess.run(["systemctl", "reboot"])
return 0
if __name__ == "__main__":
sys.exit(main())