#!/bin/bash # When a classroom checks for updates, and when it reboots after one. # # The check: daily at 03:00, caught up once after a computer that was off # at 03:00 is switched on. The reboot: only once nobody has used the # computer for half an hour and nothing is playing. The timer itself is # measured in the lab (tools/lab/update-schedule-check); here are its # settings, and classroom-reboot-when-idle run for real with a pipe # standing in for a keyboard and files standing in for sound cards. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT WORK="$WORK" python3 - "$ROOT" <<'PY' import configparser import importlib.machinery import importlib.util import os import sys import threading import time from pathlib import Path root = Path(sys.argv[1]) work = Path(os.environ["WORK"]) 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 loader = importlib.machinery.SourceFileLoader("idle", str(root / "scripts/classroom-reboot-when-idle")) spec = importlib.util.spec_from_loader("idle", loader) idle = importlib.util.module_from_spec(spec) loader.exec_module(idle) def unit(path): parser = configparser.ConfigParser(strict=False, interpolation=None) parser.optionxform = str parser.read(path) return parser print("1. the check: daily at 03:00, caught up once after being off") timer = unit(root / "systemd/updater/ekklesia-classroom-update.timer") check("every day at 03:00", timer["Timer"].get("OnCalendar") == "*-*-* 03:00:00", dict(timer["Timer"])) check("a computer off at 03:00 checks when it is next on (Persistent)", timer["Timer"].get("Persistent") == "true") check("spread over half an hour, so rooms do not all ask at once", timer["Timer"].get("RandomizedDelaySec") == "30m") check("and nothing else starts it: no boot or repeating trigger to loop on", not any(k in timer["Timer"] for k in ("OnBootSec", "OnStartupSec", "OnUnitActiveSec", "OnUnitInactiveSec", "OnActiveSec"))) check("enabled by the package on every install and upgrade", "systemctl enable ekklesia-classroom-update.timer" in (root / "packaging/debian/postinst").read_text()) check("and installed as a package file, so an upgrade always brings the packaged schedule", "systemd/updater/ekklesia-classroom-update.timer" in (root / "tools/build-release.sh").read_text() and not (root / "packaging/debian/conffiles").exists()) print() print("2. the updater: the same checks, and no reboot of its own") updater = (root / "scripts/ekklesia-classroom-update").read_text() code = "\n".join(l for l in updater.splitlines() if not l.lstrip().startswith("#")) check("only the device's own channel", 'f"{channel}/manifest.json"' in code and 'channel = config["CHANNEL"]' in code) check("only a newer version", "version_is_newer(candidate, current)" in code) check("only a manifest that makes sense", "manifest_problem(manifest)" in code) check("only a file with the manifest's SHA-256", "actual_hash = sha256(temporary_path)" in code) check("only the package and version the manifest names", "if name != PACKAGE or version != candidate:" in code) check("never reboots by itself any more", '["systemctl", "reboot"]' not in code) check("but hands the reboot to classroom-reboot-when-idle, without waiting for it", '"systemctl", "start", "--no-block", REBOOT_WHEN_IDLE' in code and 'REBOOT_WHEN_IDLE = "ekklesia-classroom-reboot-when-idle.service"' in code) service = unit(root / "systemd/updater/ekklesia-classroom-reboot-when-idle.service") check("which is never started at boot (no [Install])", "Install" not in service.sections()) check("and is not enabled by the package", "enable ekklesia-classroom-reboot-when-idle" not in (root / "packaging/debian/postinst").read_text()) build = (root / "tools/build-release.sh").read_text() check("both are packaged", "scripts/classroom-reboot-when-idle" in build and "systemd/updater/ekklesia-classroom-reboot-when-idle.service" in build) print() print("3. is anything playing?") def cards(states): base = work / f"asound-{len(list(work.glob('asound-*')))}" for n, state in enumerate(states): status = base / f"card{n}" / "pcm0p" / "sub0" / "status" status.parent.mkdir(parents=True) status.write_text(state + "\n") return base check("nothing: no cards at all", not idle.audio_playing(work / "none")) check("nothing: a card that is closed", not idle.audio_playing(cards(["closed"]))) check("nothing: a card that is open but not running", not idle.audio_playing(cards(["state: SETUP"]))) check("playing: a card that is running", idle.audio_playing(cards(["state: RUNNING\nowner_pid : 1"]))) check("playing: one of two cards running (the television's)", idle.audio_playing(cards(["closed", "state: RUNNING"]))) capture = work / "asound-capture" / "card0" / "pcm0c" / "sub0" / "status" capture.parent.mkdir(parents=True) capture.write_text("state: RUNNING\n") check("a microphone recording is not playing", not idle.audio_playing(work / "asound-capture")) print() print("4. the decision") check("half an hour untouched, nothing playing: reboot", idle.should_reboot(2000, 100, 1800, False)) check("29 minutes: not yet", not idle.should_reboot(1840, 100, 1800, False)) check("an hour untouched but a video playing: not while it plays", not idle.should_reboot(4000, 100, 1800, True)) check("half an hour by default", idle.IDLE_SECONDS == 30 * 60) print() print("5. run for real: a keyboard that is used, then left") keyboard = work / "input" / "event0" keyboard.parent.mkdir() os.mkfifo(keyboard) os.mkfifo(work / "input" / "event1") # a second device, never used marker = work / "rebooted" asound = cards(["closed"]) holder = os.open(keyboard, os.O_RDWR | os.O_NONBLOCK) # keeps the pipe open, as a device is holder1 = os.open(work / "input" / "event1", os.O_RDWR | os.O_NONBLOCK) result = {} def run(): result["code"] = idle.main(["--idle-seconds", "2", "--check-seconds", "0.2", "--input-glob", str(work / "input" / "event*"), "--asound-root", str(asound), "--reboot-command", "touch", str(marker)]) thread = threading.Thread(target=run, daemon=True) started = time.monotonic() thread.start() for _ in range(10): # typing every 0.4 s for 4 s os.write(holder, b"k" * 24) time.sleep(0.4) check("while the keyboard is used, no reboot (4 s against a 2 s limit)", not marker.exists()) thread.join(8) waited = time.monotonic() - started check(f"left alone, it reboots once the limit has passed ({waited:.1f} s)", marker.exists() and 5.5 <= waited <= 8, result) marker.unlink(missing_ok=True) playing = cards(["state: RUNNING"]) result.clear() def run_playing(): result["code"] = idle.main(["--idle-seconds", "1", "--check-seconds", "0.2", "--input-glob", str(work / "input" / "event*"), "--asound-root", str(playing), "--reboot-command", "touch", str(marker)]) thread = threading.Thread(target=run_playing, daemon=True) thread.start() time.sleep(3) check("untouched but playing for 3 s against a 1 s limit: no reboot", not marker.exists()) ((playing / "card0" / "pcm0p" / "sub0" / "status")).write_text("closed\n") stopped = time.monotonic() thread.join(6) after = time.monotonic() - stopped # Sound is seen at each check, so the limit runs from the last check that # heard it: at most one check (0.2 s here, a minute in a classroom) early. check(f"the sound stops: it waits the limit again, less at most one check, before rebooting ({after:.1f} s)", marker.exists() and 0.75 <= after <= 3, result) os.close(holder) os.close(holder1) print() print(f"{passed} passed, {failed} failed") sys.exit(1 if failed else 0) PY