#!/bin/bash set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" python3 - "$ROOT" <<'PY' import json import re import sys from pathlib import Path root = Path(sys.argv[1]) control = (root / "packaging/debian/control.template").read_text() postinst = (root / "packaging/debian/postinst").read_text() migrate = (root / "scripts/classroom-migrate").read_text() provision = (root / "scripts/classroom-provision").read_text() units = "\n".join(path.read_text() for path in (root / "systemd").rglob("*.service")) canary = (root / "update_canary.sh").read_text() validation_script = (root / "tools/validate-trixie-release.sh").read_text() layout = (root / "config/plasma/lookandfeel/contents/layouts/org.kde.plasma.desktop-layout.js").read_text() rules = (root / "config/plasma/xdg/kwinrulesrc").read_text() restrictions = (root / "config/plasma/xdg/kdeglobals").read_text() supervision = (root / "systemd/user/plasma-plasmashell.service.d/ekklesia-classroom.conf").read_text() build = (root / "tools/build-release.sh").read_text() chrome_policy = json.loads( (root / "config/chrome/policies/managed/ekklesia-classroom.json").read_text() ) passed = failed = 0 def check(label, condition): global passed, failed if condition: print(f" ok {label}") passed += 1 else: print(f" FAIL {label}") failed += 1 depends = re.search(r"^Depends: (.*)$", control, re.MULTILINE).group(1).split(", ") print("1. Trixie package contract") for package in ( "network-manager", "cups", "cups-client", "cups-filters", "foomatic-db-compressed-ppds", ): check(f"depends on {package}", package in depends) print() print("2. networking service behavior") check("NetworkManager is explicitly enabled", "systemctl enable NetworkManager.service || true" in postinst) check("NetworkManager start is best effort", "systemctl start NetworkManager.service || true" in postinst) check("online waiter is disabled", "systemctl disable NetworkManager-wait-online.service || true" in postinst) check("no classroom NetworkManager config overrides ifupdown", not (root / "config/network").exists()) print() print("3. printing service behavior") check("CUPS service and socket are enabled", "systemctl enable cups.socket cups.service || true" in postinst) check("CUPS start is best effort", "systemctl start cups.socket || true" in postinst) check("automatic remote queue browsing is disabled", "systemctl disable --now cups-browsed.service || true" in postinst) check("Avahi service and socket are disabled", "systemctl disable --now avahi-daemon.socket avahi-daemon.service || true" in postinst) check("Avahi service and socket are masked", "systemctl mask avahi-daemon.socket avahi-daemon.service || true" in postinst) check("printer setup is not a blocking boot dependency", "classroom-printer-setup" not in units) check("printer setup runs automatically in provisioning", "/usr/local/bin/classroom-printer-setup" in provision) check("printer provisioning is bounded", "--kill-after=5s 30s" in provision) check("Chrome uses the system print dialog", chrome_policy.get("DisablePrintPreview") is True) print() print("4. secondary windows are the window manager's business") # Chrome gives its main window and every window a page opens the same # class, so a rule keyed on that class maximises the lesson's popup too. # It was measured doing exactly that. The browser is told to start # maximized instead, and no rule mentions it. check("no window rule claims the browser", "google-chrome" not in rules) check("the browser asks for its own first window", "--start-maximized" in (root / "scripts/classroom-browser").read_text()) check("Wonder Ink asks for the same", "--start-maximized" in (root / "scripts/classroom-wonderink").read_text()) check("the Chrome window arbiter is gone", not (root / "scripts/classroom-chrome-windows").exists()) check("and is not packaged", "classroom-chrome-windows" not in build) print() print("4b. the panel is never allowed to stay dead") # Plasma restarts a crashed panel three times a minute and then gives up. # Measured: dead on the third kill. These lines are what make it # unconditional, and losing them would lose the room its only way around. check("restart is unconditional", "Restart=always" in supervision) check("the burst limit is lifted", "StartLimitIntervalSec=0" in supervision) check("the drop-in is packaged", "plasma-plasmashell.service.d" in build) print() print("4b2. the session settings do not fight another package") # plasma-workspace owns /etc/xdg/kdeglobals and friends. dpkg refused the # package when the classroom tried to own them too, so the room's copies # live in a directory of their own, in front of /etc/xdg. check("settings install to the classroom's own directory", "$STAGE/etc/ekklesia-classroom/xdg/" in build) # One file of ours does live under /etc/xdg, and it is not a settings # file: it is the session environment drop-in that puts the directory # above into the cascade at all. A drop-in directory is built to be # added to; a name plasma-workspace already owns is not. xdg_paths = sorted(re.findall(r'\$STAGE(/etc/xdg/[^"]+)', build)) check("what the classroom puts under /etc/xdg is two drop-ins", xdg_paths == [ "/etc/xdg/autostart/ekklesia-classroom-scale.desktop", "/etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh", ]) check("and no settings file takes a name plasma-workspace owns", not any(path.rsplit("/", 1)[0] == "/etc/xdg" for path in xdg_paths)) check("and that directory is searched first", "XDG_CONFIG_DIRS=/etc/ekklesia-classroom/xdg:/etc/xdg" in (root / "config/environment/90-ekklesia-classroom.conf").read_text()) check("the environment file is packaged", "/etc/environment.d/90-ekklesia-classroom.conf" in build) print() print("4b3. every icon the panel names is in the package") import configparser for entry in sorted((root / "config/plasma/applications").glob("*.desktop")): parser = configparser.ConfigParser(interpolation=None, strict=False) parser.read(entry) icon = parser["Desktop Entry"].get("icon", "") if not icon.startswith("ekklesia-"): continue check(f"{icon} is installed by the package", f'"$ROOT/config/icons/${{icon}}.svg"' in build or icon in build) check(f"{icon}.svg exists", (root / f"config/icons/{icon}.svg").is_file()) print() print("4b4. the power button does nothing, at both layers") logind = (root / "config/systemd/logind-ekklesia-classroom.conf").read_text() power = (root / "config/plasma/xdg/powermanagementprofilesrc").read_text() # PowerDevil holds logind's handle-power-key inhibitor while a session is # running, so one layer without the other is a button that still works. for key in ("HandlePowerKey=ignore", "HandleSuspendKey=ignore", "HandleHibernateKey=ignore", "HandleLidSwitch=ignore"): check(f"logind: {key}", key in logind) check("PowerDevil: no action on the power button", power.count("powerButtonAction=0") == 3) check("PowerDevil: no action on a lid either", power.count("lidAction=0") == 3) # Six: the three button groups, and the three profiles around them. # PowerDevil loads an idle action when it finds a group for it, so a # writable profile is one a leftover user file can add a suspend to. check("and the session cannot put an action back", power.count("[$i]") == 6) check("no idle action in any profile", not any(f"[{profile}][{action}]" in power for profile in ("AC", "Battery", "LowBattery") for action in ("SuspendSession", "DimDisplay", "DPMSControl"))) check("the logind drop-in is packaged", "/etc/systemd/logind.conf.d/ekklesia-classroom.conf" in build) check("the PowerDevil profile is packaged", "powermanagementprofilesrc" in build) check("the long hold is left alone on purpose", "firmware" in logind) print() print("4b5. the maintenance terminal is hidden and reachable") admin = (root / "config/plasma/applications/ekklesia-admin-terminal.desktop").read_text() konsole = (root / "config/plasma/applications-local/org.kde.konsole.desktop").read_text() wrapper = (root / "scripts/classroom-admin-terminal").read_text() check("the chord is the obscure one", "X-KDE-Shortcuts=Meta+Ctrl+Alt+Shift+K" in admin) check("its entry is in no application list", "NoDisplay=true" in admin) check("Konsole's own entry is shadowed and hidden", "NoDisplay=true" in konsole) check("the shadow is in /usr/local/share, not on top of Konsole's file", "$STAGE/usr/local/share/applications/" in build) check("the terminal is launched outside the classroom's restrictions", "XDG_CONFIG_DIRS=/etc/xdg" in wrapper) check("and grants no privilege of its own", "sudo" not in wrapper.replace("no sudo rule", "")) check("the wrapper is packaged", "classroom-admin-terminal" in build) check("Konsole is a dependency, so the route exists on every machine", "konsole" in (root / "packaging/debian/control.template").read_text()) check("no terminal is pinned to the panel", "konsole" not in (root / "config/plasma/lookandfeel/contents/layouts" / "org.kde.plasma.desktop-layout.js").read_text().lower()) print() print("4c. the teacher cannot reach a command line") for restriction in ("run_command=false", "shell_access=false", "action/lock_screen=false", "plasma/plasmashell/unlockedDesktop=false"): check(f"{restriction}", restriction in restrictions) check("the restrictions are immutable", "[KDE Action Restrictions][$i]" in restrictions) print() print("4d. clicking a launcher never hides an application") check("minimise on click is off", 'minimizeActiveTaskOnClick", false' in layout) check("there is no minimise button", "ButtonsOnRight=FX" in (root / "config/plasma/xdg/kwinrc").read_text()) print() print("5. bounded Trixie validation") check("container has an outer deadline", "720s" in validation_script) check("package installs have deadlines", "candidate-install 360" in validation_script) check("container service startup is suppressed", "/usr/sbin/policy-rc.d" in validation_script and "exit 101" in validation_script) print() print("6. canary release gate") validation = canary.index('"$ROOT/tools/validate-trixie-release.sh"') package_copy = canary.index('cp "$package_path"') first_rsync = canary.index("rsync -av") check("Trixie validation runs before the repository copy", validation < package_copy) check("Trixie validation runs before any upload", validation < first_rsync) # The gate checks convergence, not just that the package installs: an # upgraded room ends on the v2 channel with every setting this release # has, exactly once, and the command a technician runs is safe to run on # a machine that is already converged. check("the gate checks the upgraded channel", "'^CHANNEL=classroom-v2$'" in validation_script) check("and that every new setting arrived exactly once", "for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE" in validation_script) check("and that running the migration by hand is safe", "classroom-migrate >/tmp/migrate-by-hand.log" in validation_script) check("and that it never prints the broker password", "! grep -q 'preserve-me' /tmp/migrate-by-hand.log" in validation_script) print() print("7. one scale, applied everywhere") autostart = (root / "config/plasma/autostart/ekklesia-classroom-scale.desktop").read_text() policy = (root / "config/polkit/org.ekklesia.classroom.display.policy").read_text() settings_entry = (root / "config/plasma/applications/ekklesia-display-settings.desktop").read_text() browser = (root / "scripts/classroom-browser").read_text() wonderink = (root / "scripts/classroom-wonderink").read_text() config_module = (root / "lib/classroom_config.py").read_text() # UI_SCALE was packaged, clamped and documented, and nothing ever ran the # command that applies it. A 4K television showed a 1080p interface at # half size and every part of this section is a piece of that. check("the scale is applied at every login", "Exec=/usr/local/bin/classroom-scale" in autostart) check("by the session, which is where kscreen-doctor can be asked", "OnlyShowIn=KDE" in autostart) check("and the package installs it", "/etc/xdg/autostart/ekklesia-classroom-scale.desktop" in build) check("changing it needs an administrator password", "auth_admin" in policy and "classroom-set-scale" in policy) check("and there is no way into it from the room", "NoDisplay=true" in settings_entry and "X-KDE-Shortcuts=Meta+Ctrl+Alt+Shift+D" in settings_entry) check("2.0 is inside the range a room may ask for", "UI_SCALE_MAX = 3.0" in config_module) check("the browser is told the same number", '--force-device-scale-factor="$SCALE"' in browser) check("so is Wonder Ink", '--force-device-scale-factor="$SCALE"' in wonderink) check("both ask the one command for it", "classroom-ui-scale" in browser and "classroom-ui-scale" in wonderink) check("the clock is sized by the bar, not by a number", 'autoFontAndSize", true' in layout and "fontSize" not in layout) print() print("7b. the disc player is decided before a session starts") layout_unit = (root / "systemd/layout/ekklesia-classroom-layout.service").read_text() udev = (root / "config/udev/60-ekklesia-classroom-optical.rules").read_text() reset = (root / "scripts/classroom-desktop-reset").read_text() check("before the display manager", "Before=display-manager.service" in layout_unit) check("started at every boot", "WantedBy=graphical.target" in layout_unit) check("enabled by the package", "enable ekklesia-classroom-layout.service" in postinst) check("a drive plugged in later is noticed", 'ENV{ID_CDROM}=="1"' in udev) check("and the rules are reloaded on install", "udevadm control --reload-rules" in postinst) check("the login says so when the layout and the hardware disagree", "the disc player=" in reset) print() print("7c. the room's own popups") style = (root / "config/plasma/desktoptheme/dialogs/background.svg").read_text() plasmarc = (root / "config/plasma/xdg/plasmarc").read_text() notify = (root / "config/plasma/xdg/plasmanotifyrc").read_text() policy_notifications = chrome_policy.get("DefaultNotificationsSetting") # A Plasma style is a set of pictures with a per-file fallback to Breeze. # Replacing the notification plasmoid's QML was the other route and a QML # error there is a room with no notifications at all. for piece in ("topleft", "top", "topright", "left", "center", "right", "bottomleft", "bottom", "bottomright"): check(f"the card has a {piece}", f'id="{piece}"' in style) check("the style is chosen, immutably", "name[$i]=ekklesia" in plasmarc) check("and the package installs it", "desktoptheme/ekklesia" in build) check("the colour still comes from the session's scheme", "ColorScheme-Background" in style) check("notifications stay long enough to read across a room", "PopupTimeout=8000" in notify) # The volume display is the one thing the classroom's shell package # overrides. Plasma 6.3 loads the on-screen display from the shell # package and from nowhere else — the look and feel is not consulted, # which was measured — so this is the supported way to replace it. The # fallback declaration is what keeps the package this small: without it a # shell package loses the desktop's own defaults, and the wallpaper with # them, which was also measured. # The volume display is Plasma's own. Replacing it needs a shell package # — 6.3 reads the on-screen display from there and nowhere else — and a # shell package with one selected builds no panel at all. Measured twice, # and the panel is rebuilt from the layout at every login on purpose, so # the room would have come up empty. The scale makes Plasma's own display # the right size on a television, which is what was actually wanted. check("no shell package of our own", "plasma/shells" not in build) check("and nothing selects one", "ShellPackage" not in build) check("the volume chime is off", 'volumeFeedback", false' in layout) check("and off in the setting Plasma 6 actually reads", "AudioFeedback[$i]=false" in (root / "config/plasma/xdg/plasmaparc").read_text()) check("but the display itself is on", 'volumeOsd", true' in layout) check("web pages cannot put a notification on the television", policy_notifications == 2) check("and cannot ask to", chrome_policy.get("NotificationsAllowedForUrls") == [] and chrome_policy.get("NotificationsBlockedForUrls") == ["*"]) print() print("7c-ii. the v2 channel is its own") v2 = (root / "update_classroom_v2.sh").read_text() # A publish script that can reach another channel's manifest is one # mistake away from putting the Plasma build on a sway room. code = "\n".join(line for line in v2.splitlines() if not line.lstrip().startswith("#")) check("it publishes to classroom-v2", "CHANNEL=classroom-v2" in code) check("and names no other channel anywhere it acts", "stable" not in code and "canary" not in code) check("the same Trixie gate runs first", code.index("validate-trixie-release.sh") < code.index("rsync")) check("and the manifest goes up last", code.rindex("manifest.json") > code.index("packages/")) print() print("7d. the broker every room shares") mqtt_unit = (root / "systemd/mqtt/ekklesia-classroom-mqtt.service").read_text() parent_call = (root / "lib/classroom_parent_call.py").read_text() check("the link is restarted whatever it exits with", "Restart=always" in mqtt_unit) check("it is enabled by the package", "enable ekklesia-classroom-mqtt.service" in postinst) check("a fresh room points at the church broker", "MQTT_HOST=10.40.0.2" in config_module) check("an upgraded one is brought to the same place", 'BROKER_HOST = "10.40.0.2"' in migrate) check("Parent Call tells a missing broker from an unreachable one", "has no church network set up" in parent_call and "Cannot reach the church network" in parent_call) print() print(f"passed: {passed} failed: {failed}") raise SystemExit(1 if failed else 0) PY