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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6f2d169355
commit
690ef95aff
@@ -5,6 +5,7 @@ import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -13,6 +14,21 @@ 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):
|
||||
@@ -50,13 +66,70 @@ def installed_version():
|
||||
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]
|
||||
["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,
|
||||
@@ -134,6 +207,12 @@ def main():
|
||||
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}")
|
||||
@@ -149,10 +228,10 @@ def main():
|
||||
package_url = urljoin(base_url, manifest["package"].lstrip("/"))
|
||||
expected_hash = manifest["sha256"].lower()
|
||||
|
||||
os.makedirs("/var/cache/ekklesia-classroom", exist_ok=True)
|
||||
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||
|
||||
package_path = os.path.join(
|
||||
"/var/cache/ekklesia-classroom",
|
||||
CACHE_DIR,
|
||||
os.path.basename(manifest["package"]),
|
||||
)
|
||||
|
||||
@@ -177,6 +256,18 @@ def main():
|
||||
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user