Files
Classroom_Desktop/tests/test-deployment-support.sh
T
ekklesiaandClaude Opus 5.5 cd798a33a1 fix: a notification's Configure button no longer opens system settings
The kernel memory killer's "Memory Shortage Avoided" notice carried a
Configure button that ran kcmshell6 and opened KDE's whole Notifications
settings under the teacher's account. Reproduced in the lab.

Plasma draws that button without consulting the Kiosk policy, but
kcmshell6 and System Settings do: every settings module Debian 13's
Plasma 6.3 installs (90) is now refused in the classroom's immutable
kdeglobals, so the same click shows only "disabled by the system
administrator". The clock's Configure Digital Clock goes with
plasma/allow_configure_when_locked=false; Configure Audio Devices
disappears on its own; the disabled window's Help button opened KDE Help
Center, which the package now conflicts with (plasma-desktop only
recommends it).

The maintenance terminal runs outside the classroom's configuration and
still opens every module, verified. converged-check fails if any module
on a machine is ever left unrestricted: 126 passed in the lab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-24 06:59:53 -05:00

589 lines
30 KiB
Bash
Executable File

#!/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())
# Wonder Ink is no longer a Chrome window, so it asks in its own words.
check("Wonder Ink asks for the same", "showMaximized()" 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 drop-ins only",
xdg_paths == [
"/etc/xdg/autostart/ekklesia-classroom-display.desktop",
"/etc/xdg/autostart/ekklesia-classroom-scale.desktop",
"/etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh",
])
# Which displays are on is decided before the scale is applied to them,
# and XDG autostart order is the filename order in practice.
check("and the displays are decided before they are scaled",
xdg_paths.index("/etc/xdg/autostart/ekklesia-classroom-display.desktop")
< xdg_paths.index("/etc/xdg/autostart/ekklesia-classroom-scale.desktop"))
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("a notification's Configure button opens nothing",
"kcm_notifications=false" in restrictions)
check("and neither does any other settings module a teacher might reach",
all(f"{m}=false" in restrictions for m in (
"kcm_pulseaudio", "kcm_clock", "kcm_kscreen", "kcm_users",
"kcm_sddm", "kcm_keys", "kcm_bluetooth", "kcm_powerdevilprofilesconfig")))
check("a locked panel offers no widget settings",
"plasma/allow_configure_when_locked=false" in restrictions)
check("and the help center a settings window's Help button opens is not installed",
"khelpcenter" in re.search(r"^Conflicts: (.*)$", control, re.MULTILINE).group(1))
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 or keep-above chevron",
"ButtonsOnRight=X" in
(root / "config/plasma/xdg/kwinrc").read_text())
print()
print("5. bounded Trixie validation")
# THE PROPERTY, NOT THE NUMBER.
#
# These used to pin "720s" and "candidate-install 360" exactly. When
# 0.2.6 added LibreOffice the budgets had to grow, and the outer-deadline
# check went on passing anyway — because the commit message explaining
# the change still contained the string "720s" in a comment. A test that
# a comment can satisfy is not testing anything. What matters is that
# nothing in the gate runs unbounded.
import re as _re
outer = _re.search(r"timeout --signal=TERM --kill-after=\S+ (\d+)s", validation_script)
check("container has an outer deadline", outer is not None)
check("and it is a real one, not a token",
outer is not None and int(outer.group(1)) >= 600)
installs = _re.findall(r"run_logged (\S+-install\S*) (\d+) ", validation_script)
check("every package install has a deadline", len(installs) >= 2)
check("and each is long enough to install a desktop",
all(int(seconds) >= 600 for _name, seconds in installs))
check("nothing in the gate runs unbounded",
"run_logged" in validation_script
and validation_script.count("run_logged") >= 4)
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 -n "$package_path"')
first_upload = canary.index("release_upload_package")
check("Trixie validation runs before the repository copy", validation < package_copy)
check("Trixie validation runs before any upload", validation < first_upload)
# A version is checked before anything is built for it, and again after
# the long gate, immediately before the first upload.
check("a spent version is refused before the build",
canary.index("release_check_new_version") < canary.index("build-release.sh"))
check("and again after the gate",
validation < canary.rindex("release_check_new_version") < first_upload)
check("the stable rooms' upgrade is rehearsed on the same file before publishing",
"VALIDATION_CHANNEL=stable" in canary
and canary.index("VALIDATION_CHANNEL=stable") < first_upload)
check("the published channel is read back afterwards",
canary.index("release_verify_channel canary") > canary.index("release_publish_manifest canary"))
# 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 a deliberate channel survives",
'"^CHANNEL=${channel}\\$"' in validation_script)
check("and that channel can be stable as well as canary",
"VALIDATION_CHANNEL" in validation_script
and "stable|canary)" in validation_script)
check("the gate checks the installed version is the package's own",
"/usr/local/lib/ekklesia-classroom/VERSION" in validation_script)
check("and asks the installed updater about it",
"ekklesia-classroom-update --check" in validation_script)
check("and that an upgrade leaves Presentations off",
"grep -q '^PRESENTATIONS_ENABLED=false$'" in validation_script)
check("and that the experiment's channel comes home",
"'^CHANNEL=stable$'" 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("and asks the one command for it",
"classroom-ui-scale" in browser)
# Wonder Ink is deliberately NOT told. Chrome on Wayland was measured not
# to follow the compositor's output scale, which is why the browser has to
# be handed the number; Qt does follow it. Measured in the lab with
# UI_SCALE=2.0: kscreen-doctor reports the output at scale 2, and a Qt
# client on that session reports devicePixelRatio 2.0 without being told
# anything. Forcing it as well would apply the scale twice.
wonderink_code = "\n".join(line for line in wonderink.splitlines()
if not line.lstrip().startswith("#"))
check("Wonder Ink is not told twice",
"force-device-scale-factor" not in wonderink_code
and "QT_SCALE_FACTOR" not in wonderink_code)
# Its PAGES are, because Chrome under Plasma turned out to be scaled twice
# (dpr 2.25 at 150%, measured) and a lesson has to be the size teachers
# read it at in the browser. The window itself is left to Qt.
# A technician's command, never a teacher's: it runs in a throwaway
# profile that something owns (an unowned one crashed it), and it never
# touches the signed-in profile a teacher may have open beside it.
diag = wonderink_code[wonderink_code.index("def diagnose("):wonderink_code.index("def main(")]
check("--diagnose uses an owned, throwaway profile",
"QWebEngineProfile(app)" in diag and "build_profile" not in diag
and '"--diagnose" in sys.argv' in wonderink_code)
check("its pages start at the room's scale, which is the browser's size",
"room_zoom=ui_scale(config)" in wonderink_code
and "self._room_zoom * self._level" in wonderink_code)
check("the clock is sized by the bar, not by a number",
'autoFontAndSize", true' in layout and "fontSize" not in layout)
print()
print("8. a lesson's worksheets come back as tabs")
# The workflow itself is driven against a running session; these are the
# properties of it that can be asserted from the source, and each one is
# something whose absence was a bug at some point in the lab.
check("worksheets land in Wonder Ink's own directory",
'DOWNLOADS = Path(' in wonderink and '"Wonder Ink"' in wonderink)
check("a finished PDF is opened as a tab",
"def open_worksheet" in wonderink and "def open_pdf" in wonderink)
check("and only after its contents are checked",
'PDF_MAGIC = b"%PDF-"' in wonderink and "head != PDF_MAGIC" in wonderink)
# isFinishedChanged is never emitted for an interrupted download, which
# was measured: a worksheet that did not arrive said nothing at all.
check("terminal states are read from stateChanged, not isFinishedChanged",
"download.stateChanged.connect" in wonderink
and "isFinishedChanged.connect" not in wonderink)
check("an interrupted download says so",
"DownloadInterrupted" in wonderink and "interruptReasonString" in wonderink)
check("the path is made into a URL rather than concatenated",
"QUrl.fromLocalFile" in wonderink)
print()
print("8b. and opening them does not open the machine")
check("a navigation guard decides every local file",
"def acceptNavigationRequest" in wonderink and "url.isLocalFile()" in wonderink)
check("which consults a list of paths the window itself opened",
"def may_open" in wonderink and "self._openable" in wonderink)
check("local documents may not read other local documents",
"LocalContentCanAccessFileUrls, False" in wonderink)
check("nor reach the network",
"LocalContentCanAccessRemoteUrls, False" in wonderink)
# Qt's print dialog has a Print to File path with a "..." chooser behind
# it. Wonder Ink no longer uses that dialog: its preview offers CUPS
# printers and nothing else, and no file dialog exists anywhere in it.
check("there is no file chooser behind printing",
"QFileDialog" not in wonderink and "QPrintDialog" not in wonderink)
check("nothing downloaded is executed",
"subprocess" not in wonderink.split("def notify")[0]
and "QDesktopServices" not in wonderink
and "xdg-open" not in wonderink)
check("and no general file browser was added",
"QFileDialog" not in wonderink)
print()
print("8c. Wonder Ink and the browser stay apart")
# The download handler belongs to the Wonder Ink window and is fed by the
# Wonder Ink profile. Nothing watches a directory, and nothing asks where
# a file came from by looking at its name.
check("downloads are handled through Wonder Ink's own profile",
"profile.downloadRequested.connect(window.on_download)" in wonderink)
check("no directory watcher",
"QFileSystemWatcher" not in wonderink and "inotify" not in wonderink)
check("the browser is not touched by any of it",
"wonderink" not in browser.lower().replace("wonder ink", "")
or "Wonder Ink is a separate application" in browser)
check("and Chrome's download policy is still unmentioned",
not any(k.lower().startswith("download") for k in chrome_policy))
print()
print("8d. the 0.2.1 behaviour it must not regress")
check("cookies are still session-only",
"NoPersistentCookies" in wonderink)
check("the Present-view autoplay fix is still there",
"PlaybackRequiresUserGesture, False" in wonderink)
check("fullscreen still returns to a maximised window",
"WindowFullScreen" in wonderink)
# One text box exists, and it is the print preview's page range. Anywhere
# else a text box in this window is an address bar.
preview = wonderink[wonderink.index("class PrintPreview"):wonderink.index("\ndef notify")]
# The tab strip has one corner widget, and it is the lesson's size and
# Print — not the new-tab button a browser puts there.
controls = wonderink[wonderink.index("class LessonControls"):wonderink.index("class ZoomBadge")]
check("still no new-tab button or address bar",
wonderink.count("setCornerWidget(") == 1
and "setCornerWidget(self._controls" in wonderink
and "open_tab" not in controls and "QLineEdit" not in controls
and wonderink.count("QLineEdit(") == 1 and "QLineEdit(" in preview)
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)
# One direction only. A layout offering a player on a machine with no
# drive is a button that cannot work; a machine with a drive and
# DVD_ENABLED=false is an administrator's choice and not a fault.
check("the login says so when a player is offered with no drive",
"has no drive" in reset)
check("and reads the layout's own feature line to decide",
"var DVD = true" 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-i. the room does not introduce itself")
# A teacher does not need the name of the software. Every visible name is
# the thing it does — Parent Call, DVD, Browser, Classroom — and the
# product name stays where it belongs: package names, unit names, paths
# and topics, none of which anybody in the room reads.
visible = {}
for entry in sorted((root / "config/plasma").rglob("*.desktop")):
for line in entry.read_text().splitlines():
if line.split("=", 1)[0] in ("Name", "GenericName", "Comment"):
visible[f"{entry.name}:{line}"] = line
for script in sorted((root / "scripts").iterdir()):
if not script.is_file():
continue
for line in script.read_text().splitlines():
if "--app-name" in line:
visible[f"{script.name}:{line.strip()}"] = line
check("there are visible names to check", len(visible) > 10)
offenders = [where for where, line in visible.items() if "Ekklesia" in line]
if offenders:
for where in offenders:
print(f" {where}")
check(f"none of them says the product name ({len(visible)} checked)",
not offenders)
check("the notification card is from the room",
'--app-name="Classroom"'
in (root / "scripts/classroom-notify").read_text())
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("7c-iii. the permanent download addresses")
# .../stable/latest.deb and .../canary/latest.deb. One address per channel
# that never changes, and still no second record of which version is
# current: the pointer is a symlink to the package the manifest names.
# Both channels publish through one function, so the order below is the
# order for both.
release_lib = (root / "tools/release-lib.sh").read_text()
publish = release_lib[release_lib.index("release_publish_manifest() {"):]
publish = publish[:publish.index("\n}\n\n")]
publish_code = "\n".join(line for line in publish.splitlines()
if not line.lstrip().startswith("#"))
for channel, script in (("canary", "update_canary.sh"),
("stable", "update_stable.sh")):
text = (root / script).read_text()
check(f"{script} publishes {channel} through the shared function",
f"release_publish_manifest {channel} " in text)
check("the pointer is moved to the versioned package",
"ln -sfn ../packages/$name latest.deb.new" in publish_code)
# Atomic, so nothing downloads a half-written pointer, and after the
# manifest, so the pointer can lag it but never lead it — it can never
# offer a package the channel has not published.
check("it is swapped by rename rather than in place",
"mv -Tf latest.deb.new latest.deb" in publish_code)
check("after the manifest goes up",
publish_code.index("latest.deb") > publish_code.index("$channel/manifest.json\""))
check("and refuses to point at a package that is not there",
"test -f ../packages/$name" in publish_code)
check("the manifest is refused unless the server holds exactly that package",
publish_code.index("sha256sum") < publish_code.index("cat >"))
check("a package upload never replaces a published file",
"--ignore-existing" in release_lib)
check("and neither script writes a version anywhere but the manifest",
"latest.json" not in (root / "update_canary.sh").read_text()
and "latest.json" not in (root / "update_stable.sh").read_text())
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