#!/bin/bash # Three- and four-part versions, through everything that builds, publishes # and installs a classroom package. # # Nothing here reaches the real update server. The server is a directory, # ssh and rsync are stand-ins that act on it, and it is served over HTTP # on the loopback interface so that the real updater, the real channel # check and the real dpkg are the ones being asked. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORK="$(mktemp -d)" SERVER_PID="" cleanup() { [[ -z "$SERVER_PID" ]] || kill "$SERVER_PID" 2>/dev/null rm -rf "$WORK" } trap cleanup EXIT passed=0 failed=0 check() { local label="$1" shift if "$@" >/dev/null 2>&1; then echo " ok $label" passed=$((passed + 1)) else echo " FAIL $label" failed=$((failed + 1)) fi } refuses() { ! "$@" } # --------------------------------------------------------------------------- # A package that is nothing but its version, the way dpkg will read it. fake_deb() { local version="$1" out="$2" stage="$WORK/stage-$version" mkdir -p "$stage/DEBIAN" cat > "$stage/DEBIAN/control" </dev/null } # --------------------------------------------------------------------------- # The update server, as a directory. SERVER="$WORK/server" LOCAL="$WORK/local" mkdir -p "$SERVER/packages" "$SERVER/stable" "$SERVER/canary" \ "$LOCAL/packages" "$LOCAL/canary" "$LOCAL/stable" "$WORK/bin" for version in 0.1.45~hypr1 0.1.45 0.2.2 0.2.5 0.2.6; do fake_deb "$version" "$SERVER/packages/ekklesia-classroom_${version}_amd64.deb" cp "$SERVER/packages/ekklesia-classroom_${version}_amd64.deb" "$LOCAL/packages/" done # Not a classroom package, and on the real server all the same. echo v2 > "$SERVER/packages/ekklesia-classroom-v2.deb" manifest() { local version="$1" file="${2:-ekklesia-classroom_$1_amd64.deb}" hash hash="$(sha256sum "$SERVER/packages/$file" | awk '{print $1}')" printf '{\n "version": "%s",\n "package": "/packages/%s",\n "sha256": "%s",\n "requires_reboot": true\n}\n' \ "$version" "$file" "$hash" } manifest 0.2.2 > "$SERVER/stable/manifest.json" manifest 0.2.6 > "$SERVER/canary/manifest.json" ln -s ../packages/ekklesia-classroom_0.2.2_amd64.deb "$SERVER/stable/latest.deb" ln -s ../packages/ekklesia-classroom_0.2.6_amd64.deb "$SERVER/canary/latest.deb" cp "$SERVER/stable/manifest.json" "$LOCAL/stable/" cp "$SERVER/canary/manifest.json" "$LOCAL/canary/" # ssh HOST COMMAND: run COMMAND here. The release scripts only ever talk # to one host, and it is this directory. cat > "$WORK/bin/ssh" <<'EOF' #!/bin/bash shift exec bash -c "$*" EOF # rsync [-av] [--ignore-existing] -e ssh SOURCE HOST:DEST cat > "$WORK/bin/rsync" <<'EOF' #!/bin/bash keep=0 args=() while [[ $# -gt 0 ]]; do case "$1" in --ignore-existing) keep=1 ;; -e) shift ;; -*) ;; *) args+=("$1") ;; esac shift done source="${args[0]}" dest="${args[1]#*:}" [[ -d "$dest" ]] && dest="$dest/$(basename "$source")" if [[ "$keep" == 1 && -e "$dest" ]]; then exit 0 fi cp "$source" "$dest" EOF chmod +x "$WORK/bin/ssh" "$WORK/bin/rsync" PORT="$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')" python3 -m http.server --bind 127.0.0.1 --directory "$SERVER" "$PORT" \ >/dev/null 2>&1 & SERVER_PID=$! for _ in $(seq 1 50); do curl -fsS "http://127.0.0.1:$PORT/stable/manifest.json" >/dev/null 2>&1 && break sleep 0.1 done echo "0. the library, sourced the way the release scripts source it" # With nothing in the environment and set -u in force. Every test below # exports its own RELEASE_* values, and a default that only worked when # somebody had already set it once stopped the real build dead. check "it loads under set -u with nothing set" \ env -i PATH="$PATH" bash -c "set -euo pipefail; ROOT=/checkout; source '$ROOT/tools/release-lib.sh' [[ \$RELEASE_HOST == classroom-control ]] [[ \$RELEASE_ROOT == /srv/ekklesia/classroom-updates ]] [[ \$RELEASE_URL == https://updates.riverofglorymn.org ]] [[ \$RELEASE_LOCAL == /checkout/repo ]]" check "and build-release.sh gets as far as checking the version" \ bash -c "'$ROOT/tools/build-release.sh' 0.2.06 2>&1 | grep -q 'Invalid version: 0.2.06'" echo export PATH="$WORK/bin:$PATH" export RELEASE_HOST=test-server RELEASE_ROOT="$SERVER" \ RELEASE_URL="http://127.0.0.1:$PORT" RELEASE_LOCAL="$LOCAL" # shellcheck source=../tools/release-lib.sh source "$ROOT/tools/release-lib.sh" GOOD=(0.2.2 0.2.6 0.2.6.1 0.2.6.2 0.2.7 0.3.0 0.2.10 0.2.6.10 1.0.0 10.20.30.40) BAD=("" 0.2 0.2.6.1.1 0.2.06 00.2.6 0.2.6.0 0.2.6.01 v0.2.6 0.2.6-1 0.2.6~rc1 "0.2.6 " " 0.2.6" 0.2.6. .0.2.6 0..2 0.2.a "0.2.6;reboot" "0.2.6 0.2.7" 0.2.6+1 -0.2.6) echo "1. what a version may look like" for version in "${GOOD[@]}"; do check "the release scripts accept $version" release_version_valid "$version" done for version in "${BAD[@]}"; do check "the release scripts refuse '${version//$'\n'/\\n}'" refuses release_version_valid "$version" done # The updater decides with its own copy of the pattern, because it runs # on a classroom that has no checkout. The two copies are held to the # same list. python3 - "$ROOT/scripts/ekklesia-classroom-update" "${GOOD[@]}" -- "${BAD[@]}" <<'PY' import importlib.machinery import importlib.util import sys loader = importlib.machinery.SourceFileLoader("updater", sys.argv[1]) spec = importlib.util.spec_from_loader("updater", loader) updater = importlib.util.module_from_spec(spec) loader.exec_module(updater) rest = sys.argv[2:] split = rest.index("--") good, bad = rest[:split], rest[split + 1:] wrong = [v for v in good if not updater.valid_version(v)] wrong += [v for v in bad if updater.valid_version(v)] wrong += [v for v in (None, 26, 0.26, ["0.2.6"]) if updater.valid_version(v)] for version in wrong: print(f" FAIL the updater disagrees about {version!r}") sys.exit(1 if wrong else 0) PY check "the updater agrees about every one of them, and refuses non-strings" test $? -eq 0 echo echo "2. which version is newer, as dpkg decides it" ordered=(0.1.45~hypr1 0.1.45 0.2.2 0.2.5 0.2.6 0.2.6.1 0.2.6.2 0.2.6.10 0.2.7 0.2.10 0.3.0) for ((i = 1; i < ${#ordered[@]}; i++)); do check "${ordered[i-1]} < ${ordered[i]}" \ release_version_gt "${ordered[i]}" "${ordered[i-1]}" check "and not the other way round" \ refuses release_version_gt "${ordered[i-1]}" "${ordered[i]}" done check "0.2.6.1 is not newer than itself" refuses release_version_gt 0.2.6.1 0.2.6.1 check "the highest of everything published is 0.2.6" \ test "$(release_published_versions | release_highest)" = 0.2.6 echo echo "3. a version may be published once, and only if it is newer" check "0.2.6.1 may be published" release_check_new_version 0.2.6.1 check "0.2.7 may be published" release_check_new_version 0.2.7 check "0.2.6 may not: it is already on the server" refuses release_check_new_version 0.2.6 check "0.2.5.1 may not: it is older than 0.2.6" refuses release_check_new_version 0.2.5.1 check "0.2.2 may not" refuses release_check_new_version 0.2.2 check "0.2.06 may not: dpkg would call it 0.2.6" refuses release_check_new_version 0.2.06 check "0.2.6.0 may not" refuses release_check_new_version 0.2.6.0 # Published only in the local copy — an upload that failed after the copy # was made. The number is spent all the same. fake_deb 0.2.8 "$LOCAL/packages/ekklesia-classroom_0.2.8_amd64.deb" check "a version already copied into repo/ is spent too" refuses release_check_new_version 0.2.8 rm "$LOCAL/packages/ekklesia-classroom_0.2.8_amd64.deb" echo echo "4. uploading never replaces a published package" fake_deb 0.2.6.1 "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" check "0.2.6.1 uploads and arrives intact" \ release_upload_package "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" check "and the server copy is the built one" \ cmp "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" \ "$SERVER/packages/ekklesia-classroom_0.2.6.1_amd64.deb" before="$(sha256sum "$SERVER/packages/ekklesia-classroom_0.2.6_amd64.deb")" mkdir -p "$WORK/rebuilt" fake_deb 0.2.6 "$WORK/rebuilt/ekklesia-classroom_0.2.6_amd64.deb" ekklesia-classroom-rebuilt check "a different 0.2.6 is refused" \ refuses release_upload_package "$WORK/rebuilt/ekklesia-classroom_0.2.6_amd64.deb" check "and the published 0.2.6 is untouched" \ test "$(sha256sum "$SERVER/packages/ekklesia-classroom_0.2.6_amd64.deb")" = "$before" echo echo "5. a channel points at exactly what was uploaded" hash_0261="$(sha256sum "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" | awk '{print $1}')" stable_before="$(cat "$SERVER/stable/manifest.json")" check "a manifest promising a different checksum is refused" \ refuses release_publish_manifest canary 0.2.6.1 "$(printf '0%.0s' {1..64})" check "and canary still says 0.2.6" test "$(release_channel_version canary)" = 0.2.6 check "canary is moved to 0.2.6.1" release_publish_manifest canary 0.2.6.1 "$hash_0261" check "its manifest says so" test "$(release_channel_version canary)" = 0.2.6.1 check "latest.deb follows it" \ test "$(readlink "$SERVER/canary/latest.deb")" = ../packages/ekklesia-classroom_0.2.6.1_amd64.deb check "what a classroom downloads agrees, over HTTP" release_verify_channel canary 0.2.6.1 check "stable's manifest was not touched" \ test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before" check "and stable still verifies as 0.2.2" release_verify_channel stable 0.2.2 check "the check notices a channel saying the wrong version" \ refuses release_verify_channel stable 0.2.6.1 check "0.2.6.1 is now spent" refuses release_check_new_version 0.2.6.1 check "0.2.6.2 is next in line" release_check_new_version 0.2.6.2 # A package on the server that says it is a different version from its # name. The channel check has to read the package, not just its name. fake_deb 0.2.6 "$SERVER/packages/ekklesia-classroom_0.2.9_amd64.deb" manifest 0.2.9 > "$SERVER/canary/manifest.json" ln -sfn ../packages/ekklesia-classroom_0.2.9_amd64.deb "$SERVER/canary/latest.deb" check "a package that says it is another version fails the channel check" \ refuses release_verify_channel canary 0.2.9 rm "$SERVER/packages/ekklesia-classroom_0.2.9_amd64.deb" manifest 0.2.6.1 > "$SERVER/canary/manifest.json" ln -sfn ../packages/ekklesia-classroom_0.2.6.1_amd64.deb "$SERVER/canary/latest.deb" echo echo "6. update_stable.sh" cp "$WORK/ekklesia-classroom_0.2.6.1_amd64.deb" "$LOCAL/packages/" check "refuses a version that is not newer than stable" \ refuses "$ROOT/update_stable.sh" 0.2.2 check "refuses a malformed version" refuses "$ROOT/update_stable.sh" 0.2.06 fake_deb 0.2.9 "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb" check "refuses a package that was built but never uploaded" \ refuses "$ROOT/update_stable.sh" 0.2.9 rm "$LOCAL/packages/ekklesia-classroom_0.2.9_amd64.deb" check "stable is still 0.2.2 after all of that" \ test "$(cat "$SERVER/stable/manifest.json")" = "$stable_before" echo echo "7. update_canary.sh" check "without a version it builds nothing and names both choices" \ bash -c "'$ROOT/update_canary.sh' 2>&1 | grep -q '0.2.6.2 .*maintenance' \ && '$ROOT/update_canary.sh' 2>&1 | grep -q '0.2.7 .*feature' \ && ! '$ROOT/update_canary.sh'" check "a published version is refused before anything is built" \ bash -c "! '$ROOT/update_canary.sh' 0.2.6 2>&1 | grep -q 'Validating sources'" check "a malformed version is refused" refuses "$ROOT/update_canary.sh" 0.2.6.1.1 # --------------------------------------------------------------------------- echo echo "8. a classroom's updater, against that server" # The package database and apt, as far as the updater can tell. The # stand-in apt installs by reading the version out of the file it is # given, which is what the real one does. cat > "$WORK/bin/dpkg-query" < "$WORK/bin/apt-get" <> "$WORK/apt.log" dpkg-deb --field "\${@: -1}" Version > "$WORK/installed" EOF cat > "$WORK/bin/systemctl" <> "$WORK/systemctl.log" EOF chmod +x "$WORK/bin/dpkg-query" "$WORK/bin/apt-get" "$WORK/bin/systemctl" UPDATE_URL="http://127.0.0.1:$PORT" WORK="$WORK" SERVER="$SERVER" \ python3 - "$ROOT/scripts/ekklesia-classroom-update" <<'PY' import contextlib import hashlib import importlib.machinery import importlib.util import io import json import os import shutil import subprocess import sys from pathlib import Path work = Path(os.environ["WORK"]) server = Path(os.environ["SERVER"]) loader = importlib.machinery.SourceFileLoader("updater", sys.argv[1]) spec = importlib.util.spec_from_loader("updater", loader) updater = importlib.util.module_from_spec(spec) loader.exec_module(updater) updater.LOCKFILE = str(work / "update.lock") updater.CACHE_DIR = str(work / "cache") passed = failed = 0 def check(label, condition, detail=""): global passed, failed if condition: print(f" ok {label}") passed += 1 else: print(f" FAIL {label}") if detail: print(f" {detail}") failed += 1 def device(channel, installed): config = work / f"device-{channel}.conf" config.write_text( "# a classroom\n" f"UPDATE_URL={os.environ['UPDATE_URL']}\n" f"CHANNEL={channel}\n" "PRESENTATIONS_ENABLED=false\n" "UI_SCALE=1.25\n" ) updater.CONFIG = str(config) (work / "installed").write_text(installed) for log in ("apt.log", "systemctl.log"): (work / log).unlink(missing_ok=True) return config def run(*args): sys.argv = ["ekklesia-classroom-update", *args] out = io.StringIO() with contextlib.redirect_stdout(out): status = updater.main() return status, out.getvalue() def installs(): log = work / "apt.log" return log.read_text().splitlines() if log.exists() else [] def installed(): return (work / "installed").read_text().strip() def deb(version, name=None, package="ekklesia-classroom"): stage = work / f"stage-{version}-{package}" (stage / "DEBIAN").mkdir(parents=True, exist_ok=True) (stage / "DEBIAN" / "control").write_text( f"Package: {package}\nVersion: {version}\nArchitecture: amd64\n" "Maintainer: test\nDescription: test\n" ) out = server / "packages" / (name or f"ekklesia-classroom_{version}_amd64.deb") out.unlink(missing_ok=True) subprocess.run(["dpkg-deb", "--root-owner-group", "--build", stage, out], check=True, capture_output=True) return out def publish(channel, version, file=None, sha=None, raw=None): path = server / channel / "manifest.json" if raw is not None: path.write_text(raw) return file = file or f"ekklesia-classroom_{version}_amd64.deb" digest = sha or hashlib.sha256((server / "packages" / file).read_bytes()).hexdigest() path.write_text(json.dumps({ "version": version, "package": f"/packages/{file}", "sha256": digest, "requires_reboot": True, })) publish("stable", "0.2.2") publish("canary", "0.2.6.1") config = device("canary", "0.2.6") before = config.read_text() status, out = run("--check") check("a canary device on 0.2.6 sees 0.2.6.1 as an update", status == 0 and "Update available: 0.2.6 -> 0.2.6.1" in out, out) check("and --check installs nothing", installs() == []) status, out = run("--no-reboot") check("it installs 0.2.6.1", status == 0 and installed() == "0.2.6.1", out) check("once, from the versioned file", len(installs()) == 1 and installs()[0].endswith("ekklesia-classroom_0.2.6.1_amd64.deb"), installs()) check("--no-reboot is respected", not (work / "systemctl.log").exists()) check("its device.conf is exactly as it was", config.read_text() == before) for attempt in range(3): run("--no-reboot") check("a device on 0.2.6.1 does not install 0.2.6.1 again", len(installs()) == 1, installs()) status, out = run() check("nor reboot for it", "Already up to date" in out and not (work / "systemctl.log").exists(), out) deb("0.2.7") publish("canary", "0.2.7") status, out = run() check("a device on 0.2.6.1 recognises 0.2.7 as newer and installs it", status == 0 and installed() == "0.2.7", out) check("and reboots, because the manifest asks it to", (work / "systemctl.log").read_text().strip() == "reboot") publish("canary", "0.2.6.1") device("canary", "0.2.7") status, out = run() check("a device on 0.2.7 is not moved back to 0.2.6.1", "Already up to date" in out and installs() == [], out) # Three parts, exactly as before. publish("canary", "0.2.6") device("canary", "0.2.5") status, out = run("--no-reboot") check("a device on 0.2.5 still updates to 0.2.6", installed() == "0.2.6", out) publish("canary", "0.2.6.1") # The channels. config = device("stable", "0.2.2") status, out = run("--no-reboot") check("a stable device on 0.2.2 reads the stable manifest", f"{os.environ['UPDATE_URL']}/stable/manifest.json" in out, out) check("and does not install the canary release", installs() == [] and installed() == "0.2.2" and "Already up to date" in out, out) deb("0.2.6.2") publish("stable", "0.2.6.2") status, out = run("--no-reboot") check("a stable device goes from 0.2.2 straight to a four-part release when stable has one", installed() == "0.2.6.2", out) publish("stable", "0.2.2") (server / "packages" / "ekklesia-classroom_0.2.6.2_amd64.deb").unlink() # A room with no package installed at all reports 0.0.0 and takes anything. device("canary", "") status, out = run("--check") check("a machine with nothing installed is offered 0.2.6.1", "0.0.0 -> 0.2.6.1" in out, out) # Malformed manifests: nothing downloaded, nothing installed, and a # failure the timer's journal will show. sha = hashlib.sha256((server / "packages" / "ekklesia-classroom_0.2.6.1_amd64.deb").read_bytes()).hexdigest() bad = { "a version with a suffix": {"version": "0.2.6.1-1"}, "a version with five parts": {"version": "0.2.6.1.1"}, "a version with a leading zero": {"version": "0.2.06"}, "a version that is a number": {"version": 0.3}, "an empty version": {"version": ""}, "a version with a shell in it": {"version": "0.2.7;reboot"}, "no version at all": {"version": None}, "a package for a different version": {"version": "0.2.7", "package": "/packages/ekklesia-classroom_0.2.6.1_amd64.deb"}, "a package outside /packages": {"version": "0.2.7", "package": "/../ekklesia-classroom_0.2.7_amd64.deb"}, "a checksum that is not one": {"version": "0.2.7", "sha256": "abc"}, } for label, fields in bad.items(): manifest = {"version": "0.2.7", "package": "/packages/ekklesia-classroom_0.2.7_amd64.deb", "sha256": sha, "requires_reboot": True} manifest.update(fields) if manifest["version"] is None: del manifest["version"] publish("canary", None, raw=json.dumps(manifest)) device("canary", "0.2.6") status, out = run("--no-reboot") check(f"refused: {label}", status == 1 and installs() == [] and installed() == "0.2.6", out) for label, raw in (("a manifest that is not JSON", "502"), ("a manifest that is a list", "[]")): publish("canary", None, raw=raw) device("canary", "0.2.6") status, out = run("--no-reboot") check(f"refused: {label}", status == 1 and installs() == [], out) # The file arrives, matches its checksum, and is the wrong package. shutil.rmtree(updater.CACHE_DIR, ignore_errors=True) deb("0.2.6", name="ekklesia-classroom_0.2.7_amd64.deb") publish("canary", "0.2.7") device("canary", "0.2.6") status, out = run("--no-reboot") check("a 0.2.7 file that installs as 0.2.6 is refused", status == 1 and installs() == [] and "not ekklesia-classroom 0.2.7" in out, out) check("and not left in the cache", not list(Path(updater.CACHE_DIR).glob("*0.2.7*"))) deb("0.2.7", package="something-else", name="ekklesia-classroom_0.2.7_amd64.deb") publish("canary", "0.2.7") status, out = run("--no-reboot") check("so is a file that is some other package", status == 1 and installs() == [], out) deb("0.2.7") publish("canary", "0.2.7", sha="0" * 64) status, out = run("--no-reboot") check("and a file whose checksum is wrong", status == 1 and installs() == [] and "verification FAILED" in out, out) publish("canary", "0.2.6.1") print(f"\n updater: {passed} passed, {failed} failed") sys.exit(1 if failed else 0) PY check "every updater case above passed" test $? -eq 0 echo echo "$passed passed, $failed failed" [[ "$failed" -eq 0 ]]