feat: give every room the broker, one scale, and its own panel back
Little Waders took the Plasma build and reported what a container cannot. A disc player on a machine with no optical drive, a clock nobody at the back of the room could read, a browser at half the size of the panel around it, and Parent Call with no broker to call. The broker is the same for every classroom, so it is a default now and not something to fill in room by room. classroom-migrate brings an existing room to the same place without touching its name, its username or its password, and Parent Call distinguishes a broker it cannot reach from one that was never set up: those send somebody to look at completely different things. The disc player's detection was right and ran too late — alongside the session rather than before it, and the panel is built from the layout the moment a session starts. It runs before the display manager now, and again from udev when a drive is plugged in. UI_SCALE had a ceiling of 1.5, which cannot express a 4K television; a clock pinned at 15 points, which is the same size at every scale; and a browser that was never told the number at all. All three are fixed and the room doubles together at 200%. This also brings 0.1.47's work into the repository, where it had only ever existed in a build directory under /tmp: the hidden display dialog, its authenticated writer, the polkit action, and the church's own wallpaper. One piece of it is deliberately left out. The custom volume display needed a shell package of our own, and with one selected plasmashell builds no panel at all — measured twice, with that build's own files, on a classroom that deletes its panel configuration at every login by design. The lab connected to the real church broker the moment the package defaulted to it. Nothing was left behind, which was checked, but the guard that should have stopped it had never worked: it used iptables, which the lab image does not have, and probed with /dev/tcp under dash, where that always fails. Both reported success. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
926668dc9b
commit
34fd02e08f
@@ -36,7 +36,16 @@ with tempfile.TemporaryDirectory() as directory:
|
||||
ensure_device_config(path)
|
||||
config = read_device_config(path)
|
||||
check("fresh config is created", path.is_file())
|
||||
check("MQTT defaults off", config.get("MQTT_ENABLED") == "false")
|
||||
# Every classroom talks to the same broker, so a fresh machine is
|
||||
# pointed at it rather than left switched off waiting for somebody to
|
||||
# remember. The rooms that were set up before there was a broker are
|
||||
# brought to the same place by classroom-migrate.
|
||||
check("the broker is on by default", config.get("MQTT_ENABLED") == "true")
|
||||
check("and points at the church broker",
|
||||
config.get("MQTT_HOST") == "10.40.0.2")
|
||||
check("on the usual port", config.get("MQTT_PORT") == "1883")
|
||||
check("with no credentials invented for it",
|
||||
config.get("MQTT_USERNAME") == "" and config.get("MQTT_PASSWORD") == "")
|
||||
check("stable remains the fresh-install default", config.get("CHANNEL") == "stable")
|
||||
|
||||
print()
|
||||
|
||||
Executable
+153
@@ -0,0 +1,153 @@
|
||||
#!/bin/bash
|
||||
set -uo pipefail
|
||||
|
||||
# What an upgrade does to a room's own settings file.
|
||||
#
|
||||
# device.conf is the one file in the classroom that belongs to the church
|
||||
# rather than to the package: the room's name, and the broker password.
|
||||
# An upgrade edits it, so an upgrade can also ruin it. These are the cases
|
||||
# that matter, run against the real code — the migration's own Python is
|
||||
# lifted out of the script rather than copied here, so a change to one
|
||||
# cannot pass by disagreeing with the other.
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
python3 - "$ROOT" <<'PY'
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
script = (root / "scripts/classroom-migrate").read_text()
|
||||
|
||||
block = re.search(r'python3 - "\$DEVICE_CONFIG" <<\'PY\'\n(.*?)\nPY\n',
|
||||
script, re.S)
|
||||
|
||||
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
|
||||
|
||||
|
||||
check("the migration's device.conf code was found", block is not None)
|
||||
|
||||
if block is None:
|
||||
sys.exit(1)
|
||||
|
||||
migration = block.group(1)
|
||||
|
||||
|
||||
def migrate(text):
|
||||
"""Run the real migration over a device.conf and return it."""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
source = Path(directory) / "migrate.py"
|
||||
source.write_text(migration)
|
||||
config = Path(directory) / "device.conf"
|
||||
config.write_text(text)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(source), str(config)],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
|
||||
return config.read_text(), result.stdout + result.stderr
|
||||
|
||||
|
||||
print("1. a room from the Hyprland experiment")
|
||||
|
||||
BEFORE = """# Ekklesia Classroom device configuration
|
||||
|
||||
UPDATE_URL=https://updates.riverofglorymn.org
|
||||
CHANNEL=hyprland
|
||||
LIVE_ENABLED=false
|
||||
AUDIO_OUTPUT=auto
|
||||
UI_SCALE=2.0
|
||||
ROOM=little-waders
|
||||
MQTT_ENABLED=false
|
||||
MQTT_HOST=
|
||||
MQTT_PORT=1883
|
||||
MQTT_USERNAME=classroom
|
||||
MQTT_PASSWORD=hunter2-not-a-real-one
|
||||
COMPOSITOR=hyprland
|
||||
HYPR_APPEARANCE=visual
|
||||
"""
|
||||
|
||||
after, output = migrate(BEFORE)
|
||||
|
||||
check("the broker is switched on", "MQTT_ENABLED=true" in after)
|
||||
check("and given the church address", "MQTT_HOST=10.40.0.2" in after)
|
||||
check("the port is left as it was", "MQTT_PORT=1883" in after)
|
||||
check("the room keeps its name", "ROOM=little-waders" in after)
|
||||
check("the username is untouched", "MQTT_USERNAME=classroom" in after)
|
||||
check("the password is untouched",
|
||||
"MQTT_PASSWORD=hunter2-not-a-real-one" in after)
|
||||
check("and is never printed", "hunter2" not in output)
|
||||
check("the compositor setting is gone", "COMPOSITOR" not in after)
|
||||
check("and the appearance setting with it", "HYPR_APPEARANCE" not in after)
|
||||
check("the channel moves off the experiment",
|
||||
"CHANNEL=classroom-v2" in after)
|
||||
check("the room's scale is preserved", "UI_SCALE=2.0" in after)
|
||||
check("and everything else is left alone",
|
||||
"UPDATE_URL=https://updates.riverofglorymn.org" in after
|
||||
and "AUDIO_OUTPUT=auto" in after)
|
||||
|
||||
print()
|
||||
print("2. a room already pointed at a broker of its own")
|
||||
|
||||
OWN = """CHANNEL=a-channel-somebody-named
|
||||
MQTT_ENABLED=true
|
||||
MQTT_HOST=broker.example.invalid
|
||||
MQTT_PORT=8883
|
||||
MQTT_PASSWORD=leave-me
|
||||
"""
|
||||
|
||||
after, output = migrate(OWN)
|
||||
|
||||
check("a deliberate address is not overwritten",
|
||||
"MQTT_HOST=broker.example.invalid" in after)
|
||||
check("nor a deliberate port", "MQTT_PORT=8883" in after)
|
||||
check("a channel nobody here named is left alone",
|
||||
"CHANNEL=a-channel-somebody-named" in after)
|
||||
check("and its password is still there", "MQTT_PASSWORD=leave-me" in after)
|
||||
|
||||
print()
|
||||
print("3. a room old enough to have no broker settings at all")
|
||||
|
||||
OLD = """CHANNEL=canary
|
||||
UI_SCALE=1.0
|
||||
"""
|
||||
|
||||
after, output = migrate(OLD)
|
||||
|
||||
check("the settings are added", "MQTT_ENABLED=true" in after
|
||||
and "MQTT_HOST=10.40.0.2" in after and "MQTT_PORT=1883" in after)
|
||||
check("no credentials are invented",
|
||||
"MQTT_USERNAME" not in after and "MQTT_PASSWORD" not in after)
|
||||
# stable and canary are both the sway line. A machine that has just taken
|
||||
# the Plasma package and is left pointed at one of them is handed the old
|
||||
# desktop back by the next release there: the upgrade undoing itself.
|
||||
check("canary moves onto the v2 channel", "CHANNEL=classroom-v2" in after)
|
||||
|
||||
after, _ = migrate("CHANNEL=stable\nUI_SCALE=1.0\n")
|
||||
check("and so does stable", "CHANNEL=classroom-v2" in after)
|
||||
|
||||
print()
|
||||
print("4. running it twice changes nothing the second time")
|
||||
|
||||
once, _ = migrate(BEFORE)
|
||||
twice, _ = migrate(once)
|
||||
check("the second run is a no-op", once == twice)
|
||||
|
||||
print()
|
||||
print(f"{passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
PY
|
||||
@@ -12,6 +12,7 @@ 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()
|
||||
@@ -21,7 +22,7 @@ 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()
|
||||
policy = json.loads(
|
||||
chrome_policy = json.loads(
|
||||
(root / "config/chrome/policies/managed/ekklesia-classroom.json").read_text()
|
||||
)
|
||||
|
||||
@@ -67,7 +68,8 @@ check("Avahi service and socket are masked", "systemctl mask avahi-daemon.socket
|
||||
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", policy.get("DisablePrintPreview") is True)
|
||||
check("Chrome uses the system print dialog",
|
||||
chrome_policy.get("DisablePrintPreview") is True)
|
||||
|
||||
print()
|
||||
print("4. secondary windows are the window manager's business")
|
||||
@@ -103,10 +105,13 @@ check("settings install to the classroom's own directory",
|
||||
# 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 = re.findall(r'\$STAGE(/etc/xdg/[^"]+)', build)
|
||||
check("the only file under /etc/xdg is the session env drop-in",
|
||||
xdg_paths == ["/etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh"])
|
||||
check("no settings file takes a name plasma-workspace owns",
|
||||
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
|
||||
@@ -207,6 +212,127 @@ 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)
|
||||
|
||||
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)
|
||||
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ROOT="$ROOT" PYTHONPATH="$ROOT/lib" python3 - <<'PY'
|
||||
import os, pathlib, runpy, subprocess, sys, tempfile
|
||||
from classroom_config import ui_scale
|
||||
root = pathlib.Path(os.environ['ROOT'])
|
||||
helper = runpy.run_path(str(root/'scripts/classroom-set-scale'))
|
||||
original = '# keep this\nCHANNEL=classroom-v2\nMQTT_PASSWORD=literal $ and = characters\nUI_SCALE=1.25\nCUSTOM=x\nUI_SCALE=1.5\n'
|
||||
result = helper['updated'](original, 2.0)
|
||||
assert 'MQTT_PASSWORD=literal $ and = characters\n' in result
|
||||
assert result.count('UI_SCALE=') == 1
|
||||
assert helper['updated'](result, 2.0) == result
|
||||
assert 'CUSTOM=x\n' in result
|
||||
assert ui_scale({'UI_SCALE': '2'}) == 2
|
||||
assert ui_scale({'UI_SCALE': '3'}) == 3
|
||||
for bad in ('nan', 'inf', '-inf', 'oops'):
|
||||
assert ui_scale({'UI_SCALE': bad}) == 1
|
||||
assert ui_scale({'UI_SCALE': '999'}) == 3
|
||||
assert ui_scale({'UI_SCALE': '-1'}) == 0.8
|
||||
# Run the actual migration's config code in a temporary directory: no services.
|
||||
script = (root/'scripts/classroom-migrate').read_text()
|
||||
code = script.split("<<'PY'\n", 1)[1].split('\nPY\n', 1)[0]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = pathlib.Path(tmp)/'device.conf'
|
||||
for channel in ('stable', 'canary', 'hyprland', 'classroom-v2', 'custom'):
|
||||
path.write_text(original.replace('CHANNEL=classroom-v2', 'CHANNEL='+channel))
|
||||
subprocess.run([sys.executable, '-c', code, str(path)], check=True, capture_output=True)
|
||||
expected = channel if channel in ('classroom-v2', 'custom') else 'classroom-v2'
|
||||
assert 'CHANNEL='+expected+'\n' in path.read_text()
|
||||
assert 'MQTT_PASSWORD=literal $ and = characters\n' in path.read_text()
|
||||
print('Display settings: scale bounds, config preservation, idempotence and channel migration passed.')
|
||||
PY
|
||||
@@ -158,8 +158,8 @@ check(
|
||||
'OBSOLETE_KEYS = ("COMPOSITOR", "HYPR_APPEARANCE")' in migrate,
|
||||
)
|
||||
check(
|
||||
"and comes off a channel that would undo the upgrade",
|
||||
'OBSOLETE_CHANNELS = {"hyprland"}' in migrate,
|
||||
"and comes off any channel that would undo the upgrade",
|
||||
'OBSOLETE_CHANNELS = {"stable", "canary", "hyprland"}' in migrate,
|
||||
)
|
||||
check(
|
||||
"the rest of device.conf is rewritten line by line, not regenerated",
|
||||
|
||||
Reference in New Issue
Block a user