diff --git a/VERSION b/VERSION index 072d0fa..9f42295 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.36 +0.1.37 diff --git a/config/chrome/policies/managed/ekklesia-classroom.json b/config/chrome/policies/managed/ekklesia-classroom.json index eacb8e2..80d99ff 100644 --- a/config/chrome/policies/managed/ekklesia-classroom.json +++ b/config/chrome/policies/managed/ekklesia-classroom.json @@ -14,6 +14,7 @@ "PasswordManagerEnabled": false, "PasswordLeakDetectionEnabled": false, "BackgroundModeEnabled": false, + "DisablePrintPreview": true, "CookiesSessionOnlyForUrls": [ "[*.]wonderink.org" diff --git a/docs/dvd.md b/docs/dvd.md index bb11923..b17e39d 100644 --- a/docs/dvd.md +++ b/docs/dvd.md @@ -13,9 +13,8 @@ DVD_ENABLED=true Then restart the session (reboot, or restart `classroom-dock`). With `DVD_ENABLED=false`, or the key absent, the DVD button is not built -at all and no drive polling happens. Older endpoints get a commented -`DVD_ENABLED=false` appended on upgrade so the option is discoverable, -which leaves their behaviour unchanged. +at all and no drive polling happens. The LIVE placeholder is controlled +independently by `LIVE_ENABLED`; see [live.md](live.md). ## Behaviour diff --git a/docs/live.md b/docs/live.md new file mode 100644 index 0000000..ca8feef --- /dev/null +++ b/docs/live.md @@ -0,0 +1,19 @@ +# Sanctuary LIVE dock capability + +Sanctuary live viewing is not implemented. The existing placeholder can be +shown on development devices or hidden on ordinary classrooms with +`/etc/ekklesia-classroom/device.conf`: + +``` +LIVE_ENABLED=false +``` + +With `LIVE_ENABLED=false`, or when the key is absent, the LIVE button is not +built at all, so it leaves no empty space and starts no live-specific work. + +With `LIVE_ENABLED=true`, the current LIVE icon and placeholder notification +appear unchanged. This flag does not enable playback or start a streaming +service. + +Older endpoints receive one commented `LIVE_ENABLED=false` setting on +upgrade. Existing device configuration values and comments are preserved. diff --git a/docs/networking.md b/docs/networking.md new file mode 100644 index 0000000..9d8b462 --- /dev/null +++ b/docs/networking.md @@ -0,0 +1,42 @@ +# Classroom networking + +Ethernet remains the normal classroom connection. The package also installs +and enables NetworkManager so an administrator can add Wi-Fi when a room needs +it; there is no classroom Wi-Fi screen and no network name or password in the +package. + +## Add Wi-Fi + +From a terminal or SSH session, run: + +``` +sudo nmtui +``` + +Choose **Activate a connection**, select the church Wi-Fi network, enter its +password, and activate it. NetworkManager saves the connection as a system +connection and reconnects it automatically after reboot. + +Useful checks: + +``` +nmcli device status +nmcli connection show +systemctl status NetworkManager +``` + +There is nothing to configure on a computer without Wi-Fi hardware. +NetworkManager starts normally, sees no Wi-Fi device, and Ethernet continues +to work. + +## Existing Ethernet configuration + +Do not remove or rewrite `/etc/network/interfaces` as part of this procedure. +Debian Trixie's NetworkManager configuration uses the `ifupdown` plugin with +`managed=false`, so any Ethernet interface declared there remains under +ifupdown's control. NetworkManager manages other interfaces, including Wi-Fi. +This preserves an existing static or ifupdown Ethernet setup. + +The package disables `NetworkManager-wait-online.service`; no classroom +feature is allowed to hold boot waiting for Wi-Fi or Internet access. Saved +connections still reconnect automatically. diff --git a/docs/printing.md b/docs/printing.md new file mode 100644 index 0000000..f0ebc07 --- /dev/null +++ b/docs/printing.md @@ -0,0 +1,77 @@ +# Classroom printing + +The package installs and enables CUPS, its client/filter stack, and Debian's +compressed Foomatic PPD database. Chrome is configured to open the system +print dialog, which shows the queues configured in CUPS. + +The target Ricoh Aficio MP C3502 supports PCL 5c and PCL 6 as standard; +PostScript 3 is optional. Debian Trixie does not provide a model-specific +C3502 PPD in the installed driver set, so the classroom helper uses Trixie's +recommended generic color PCL 6/PCL XL driver. This works with the printer's +standard language and does not require a vendor binary. + +## Configure the church color printer + +Give the helper the printer's IP address or DNS name: + +``` +sudo classroom-printer-setup 10.20.30.40 +``` + +This creates or updates one queue named `Classroom_Color`, points it directly +to `socket://10.20.30.40:9100`, enables it, accepts jobs, and makes it the +system default. The printer's address is never stored in the package. + +An explicit URI is also accepted when deployment requires another supported +transport: + +``` +sudo classroom-printer-setup ipp://10.20.30.40/ipp/print +sudo classroom-printer-setup --no-default socket://10.20.30.40:9100 +``` + +The command is idempotent: running it again updates the same queue. It does not +contact the printer while configuring CUPS, and an unreachable printer only +leaves jobs queued; it never affects boot. + +Automatic remote queue browsing is disabled. `cups-browsed` is stopped and +disabled if another package installed it, so unrelated advertised label and +check-in printers are not turned into local classroom queues. + +## Administration + +List the available Debian drivers, including the selected PCL path: + +``` +lpinfo -m +lpinfo -m | grep -i -E 'Ricoh|PCL|PostScript|IPP Everywhere' +``` + +Show the classroom queue, its URI, and the system default: + +``` +lpstat -p Classroom_Color -v +lpstat -d +``` + +Set the default manually: + +``` +sudo lpadmin -d Classroom_Color +``` + +Print CUPS's test page: + +``` +lp -d Classroom_Color /usr/share/cups/data/testprint +``` + +Inspect or cancel queued jobs: + +``` +lpstat -o Classroom_Color +cancel -a Classroom_Color +``` + +The printer language choice is based on Ricoh's published +[MP C3002/C3502 specifications](https://assets.ricoh-usa.com/j2jqn9lauv41/1CzJla6qwny2d9oBVHHlID/5bfa366a01cf82162ea24287f99c8af9/aficiompc3002.pdf). diff --git a/docs/provisioning.md b/docs/provisioning.md index 55654c3..6936bd7 100644 --- a/docs/provisioning.md +++ b/docs/provisioning.md @@ -51,8 +51,17 @@ classroom package and apply as soon as Chrome exists. Only for rooms with `DVD_ENABLED=true`. See [dvd.md](dvd.md). +## Networking and printing + +NetworkManager and CUPS are Debian package dependencies, not deferred network +provisioning steps. Their services are enabled by the package and neither +requires Internet access or hardware to be present. + +- Wi-Fi administration: [networking.md](networking.md) +- Church color printer setup: [printing.md](printing.md) + ## What provisioning does not do -It never upgrades or reconfigures anything already installed, and it +The boot provisioning command never upgrades or reconfigures anything already installed, and it never touches a repository it did not add. Classroom software updates are the OTA channel's job, not this. diff --git a/docs/testing.md b/docs/testing.md index b86a058..8376da7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -19,6 +19,31 @@ before promoting to stable with `./update_stable.sh`. - [ ] A machine with no network still boots to a working shell, and provisioning succeeds at a later boot once it is online +## Networking + +- [ ] `nmtui` is installed and `NetworkManager.service` is enabled +- [ ] An Ethernet interface declared in `/etc/network/interfaces` remains + managed by ifupdown and keeps its existing address +- [ ] `sudo nmtui` can save and activate a Wi-Fi connection +- [ ] The saved Wi-Fi connection reconnects after reboot +- [ ] A machine with no Wi-Fi adapter reaches the classroom normally without + an online-wait delay or NetworkManager failure +- [ ] Ethernet-only operation is unchanged + +## Printing + +- [ ] `cups.service` is enabled and `lpstat -r` reports that the scheduler runs +- [ ] `lpinfo -m` contains the generic color PCL 6/PCL XL driver documented in + [printing.md](printing.md) +- [ ] `sudo classroom-printer-setup ` creates only + `Classroom_Color`, enables it, and makes it the system default +- [ ] Repeating the helper updates the same queue without creating a duplicate +- [ ] Chrome opens the system print dialog and shows the configured queue +- [ ] Unrelated label/check-in printers are not automatically added +- [ ] The CUPS test page prints in color on the Ricoh MP C3502 +- [ ] With the Ricoh powered off, the classroom still boots normally and a job + simply remains queued + ## UI scale UI_SCALE sets sway's output scale, so it scales the whole session. @@ -169,6 +194,19 @@ With `DVD_ENABLED=true`: - [ ] Commercial CSS disc plays without anyone having run codec setup by hand +## Sanctuary LIVE capability + +With `LIVE_ENABLED=false`: + +- [ ] No LIVE button or spacing hole appears in the dock +- [ ] No live-specific service or process starts + +With `LIVE_ENABLED=true`: + +- [ ] LIVE appears after DVD when DVD is enabled, and after Browser otherwise +- [ ] Clicking it shows the existing placeholder notification +- [ ] No live playback is attempted + ### DVD mode - [ ] Pressing DVD covers the **whole** display immediately, dock and @@ -206,3 +244,8 @@ With `DVD_ENABLED=true`: - [ ] SHA-256 verification passes - [ ] Install succeeds and the endpoint reboots - [ ] Everything above still works after the update +- [ ] Upgrade from 0.1.36 preserves every existing `device.conf` value +- [ ] `LIVE_ENABLED=false` is appended exactly once on upgrade and remains + single after reinstall +- [ ] `tools/validate-trixie-release.sh` passes against the 0.1.37 package and + the 0.1.36 package diff --git a/lib/classroom_config.py b/lib/classroom_config.py index 8f9a298..2045231 100644 --- a/lib/classroom_config.py +++ b/lib/classroom_config.py @@ -19,6 +19,70 @@ UI_SCALE_MIN = 0.8 UI_SCALE_MAX = 1.5 +DEVICE_CONFIG_TEMPLATE = """# Ekklesia Classroom device configuration + +UPDATE_URL=https://updates.riverofglorymn.org +CHANNEL=stable + +# Set to true on classrooms that have a DVD drive. +DVD_ENABLED=false + +# Show the Sanctuary LIVE placeholder in the dock. Live viewing is not +# implemented yet; this only controls whether the current placeholder appears. +LIVE_ENABLED=false + +# Size of the dock, clock and notifications, 0.8 to 1.5. +# Larger rooms and larger TVs want a larger number. +UI_SCALE=1.0 + +# Church message broker. The room is this machine's hostname unless ROOM +# is set. Leave MQTT_ENABLED=false until a broker exists. +MQTT_ENABLED=false +MQTT_HOST= +MQTT_PORT=1883 +MQTT_USERNAME= +MQTT_PASSWORD= +""" + + +# Sections added after the first classroom release. Keeping them separate lets +# an upgrade make a setting discoverable without replacing anything an +# administrator has already put in device.conf. +DEVICE_CONFIG_SECTIONS = ( + ( + "DVD_ENABLED", + """# Set to true on classrooms that have a DVD drive. +DVD_ENABLED=false +""", + ), + ( + "LIVE_ENABLED", + """# Show the Sanctuary LIVE placeholder in the dock. Live viewing is not +# implemented yet; this only controls whether the current placeholder appears. +LIVE_ENABLED=false +""", + ), + ( + "UI_SCALE", + """# Size of the dock, clock and notifications, 0.8 to 1.5. +# Larger rooms and larger TVs want a larger number. +UI_SCALE=1.0 +""", + ), + ( + "MQTT_ENABLED", + """# Church message broker. The room is this machine's hostname unless ROOM +# is set. Leave MQTT_ENABLED=false until a broker exists. +MQTT_ENABLED=false +MQTT_HOST= +MQTT_PORT=1883 +MQTT_USERNAME= +MQTT_PASSWORD= +""", + ), +) + + def read_device_config(path=None): config = {} path = Path(path) if path is not None else CONFIG_FILE @@ -45,6 +109,47 @@ def enabled(config, key, default=False): return config[key].lower() in TRUTHY +def dock_items(config): + """Dock items in display order, with disabled capabilities omitted.""" + items = ["browser"] + + if enabled(config, "DVD_ENABLED"): + items.append("dvd") + + if enabled(config, "LIVE_ENABLED"): + items.append("live") + + return tuple(items) + + +def ensure_device_config(path=None): + """Create device.conf or append any missing settings exactly once. + + Existing bytes are otherwise left alone: per-room settings, comments and + even keys this release does not know about all survive an upgrade. + """ + path = Path(path) if path is not None else CONFIG_FILE + + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(DEVICE_CONFIG_TEMPLATE) + return + + text = path.read_text() + config = read_device_config(path) + additions = [] + + for key, section in DEVICE_CONFIG_SECTIONS: + if key not in config: + additions.append(section.rstrip("\n")) + + if not additions: + return + + separator = "" if not text or text.endswith("\n") else "\n" + path.write_text(text + separator + "\n\n".join(additions) + "\n") + + def ui_scale(config): """UI_SCALE, clamped to something a classroom can actually use.""" raw = config.get("UI_SCALE", "") diff --git a/packaging/debian/control.template b/packaging/debian/control.template index af1aaf2..4205f98 100644 --- a/packaging/debian/control.template +++ b/packaging/debian/control.template @@ -4,7 +4,7 @@ Section: misc Priority: optional Architecture: amd64 Maintainer: Ekklesia -Depends: sway, swaybg, waybar, mako-notifier, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, cups, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, adwaita-icon-theme, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt +Depends: sway, swaybg, waybar, mako-notifier, dbus-user-session, xwayland, fonts-dejavu, fonts-noto, network-manager, pipewire, pipewire-audio, wireplumber, cups, cups-client, cups-filters, foomatic-db-compressed-ppds, python3-gi, python3-gi-cairo, gir1.2-gtk-3.0, gir1.2-gtklayershell-0.1, adwaita-icon-theme, libnotify-bin, curl, gnupg, ca-certificates, python3-paho-mqtt Recommends: vlc Description: Ekklesia managed classroom environment A minimal Wayland classroom environment for Ekklesia-managed church computers. diff --git a/packaging/debian/postinst b/packaging/debian/postinst index 04badda..5d4e2b5 100755 --- a/packaging/debian/postinst +++ b/packaging/debian/postinst @@ -30,6 +30,7 @@ chmod 0755 /usr/local/bin/classroom-scale chmod 0755 /usr/local/bin/classroom-shutdown chmod 0755 /usr/local/bin/classroom-chrome-setup chmod 0755 /usr/local/bin/classroom-provision +chmod 0755 /usr/local/bin/classroom-printer-setup # Reading the optical drive needs group cdrom. Takes effect at the next # login, which the OTA reboot provides. @@ -37,62 +38,14 @@ if getent group cdrom >/dev/null 2>&1; then adduser classroom cdrom >/dev/null 2>&1 || true fi -if [ ! -f /etc/ekklesia-classroom/device.conf ]; then - cat > /etc/ekklesia-classroom/device.conf <<'CONFIG' -# Ekklesia Classroom device configuration +# Create device.conf on a new endpoint, or append only settings an older +# endpoint does not yet know. The helper preserves every existing byte apart +# from the append, so per-room choices always survive an upgrade. +PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 - <<'PY' +from classroom_config import ensure_device_config -UPDATE_URL=https://updates.riverofglorymn.org -CHANNEL=stable - -# Set to true on classrooms that have a DVD drive. -DVD_ENABLED=false - -# Size of the dock, clock and notifications, 0.8 to 1.5. -# Larger rooms and larger TVs want a larger number. -UI_SCALE=1.0 - -# Church message broker. The room is this machine's hostname unless ROOM -# is set. Leave MQTT_ENABLED=false until a broker exists. -MQTT_ENABLED=false -MQTT_HOST= -MQTT_PORT=1883 -MQTT_USERNAME= -MQTT_PASSWORD= -CONFIG -fi - -# Make the settings discoverable on classrooms installed before they -# existed. Both defaults are what those rooms already do, so nothing -# about their behaviour changes. -if ! grep -q '^DVD_ENABLED=' /etc/ekklesia-classroom/device.conf; then - cat >> /etc/ekklesia-classroom/device.conf <<'CONFIG' - -# Set to true on classrooms that have a DVD drive. -DVD_ENABLED=false -CONFIG -fi - -if ! grep -q '^UI_SCALE=' /etc/ekklesia-classroom/device.conf; then - cat >> /etc/ekklesia-classroom/device.conf <<'CONFIG' - -# Size of the dock, clock and notifications, 0.8 to 1.5. -# Larger rooms and larger TVs want a larger number. -UI_SCALE=1.0 -CONFIG -fi - -if ! grep -q '^MQTT_ENABLED=' /etc/ekklesia-classroom/device.conf; then - cat >> /etc/ekklesia-classroom/device.conf <<'CONFIG' - -# Church message broker. The room is this machine's hostname unless ROOM -# is set. Leave MQTT_ENABLED=false until a broker exists. -MQTT_ENABLED=false -MQTT_HOST= -MQTT_PORT=1883 -MQTT_USERNAME= -MQTT_PASSWORD= -CONFIG -fi +ensure_device_config() +PY # The broker password lives here, so the file stops being world readable. # Everything that reads it runs as root or as the classroom user. @@ -101,6 +54,22 @@ chmod 0640 /etc/ekklesia-classroom/device.conf systemctl daemon-reload || true +# NetworkManager ships nmtui and deliberately leaves interfaces declared in +# /etc/network/interfaces under ifupdown's control (managed=false). This gives +# administrators saved, auto-reconnecting Wi-Fi without taking over an +# existing static Ethernet setup. No classroom service needs to hold boot for +# NetworkManager's online wait, so keep that waiter disabled. +systemctl enable NetworkManager.service || true +systemctl disable NetworkManager-wait-online.service || true +systemctl start NetworkManager.service || true + +# CUPS is ready at boot, but no queue is created until an administrator runs +# classroom-printer-setup. cups-browsed is unnecessary here and would expose +# every advertised printer on the church network if installed independently. +systemctl enable cups.socket cups.service || true +systemctl disable --now cups-browsed.service || true +systemctl start cups.socket || true + systemctl enable ekklesia-classroom-update.timer || true systemctl start ekklesia-classroom-update.timer || true diff --git a/scripts/classroom-dock b/scripts/classroom-dock index f27028e..dd34ca9 100755 --- a/scripts/classroom-dock +++ b/scripts/classroom-dock @@ -18,7 +18,7 @@ sys.path.insert(0, "/usr/local/lib/ekklesia-classroom") import classroom_style as style # noqa: E402 from classroom_config import ( # noqa: E402 SHELL_SCALE, - enabled, + dock_items, read_device_config, scaled, ) @@ -311,6 +311,8 @@ class ClassroomShell(Gtk.Window): ) dock.get_style_context().add_class("dock") + items = dock_items(self.config) + dock.pack_start( self.make_button( "browser", @@ -322,11 +324,7 @@ class ClassroomShell(Gtk.Window): 0, ) - if enabled( - self.config, - "DVD_ENABLED", - False, - ): + if "dvd" in items: dock.pack_start( self.make_button( "dvd", @@ -342,19 +340,20 @@ class ClassroomShell(Gtk.Window): self.start_disc_watch() - dock.pack_start( - self.make_button( - "sanctuary", - self.sanctuary_icon(), - lambda *_: notify( - "Sanctuary", - "Sanctuary live view is being prepared.", + if "live" in items: + dock.pack_start( + self.make_button( + "sanctuary", + self.sanctuary_icon(), + lambda *_: notify( + "Sanctuary", + "Sanctuary live view is being prepared.", + ), ), - ), - False, - False, - 0, - ) + False, + False, + 0, + ) return dock diff --git a/scripts/classroom-printer-setup b/scripts/classroom-printer-setup new file mode 100755 index 0000000..44fddf7 --- /dev/null +++ b/scripts/classroom-printer-setup @@ -0,0 +1,111 @@ +#!/bin/bash +set -euo pipefail + +# Configure the one explicitly managed classroom printer. The Ricoh Aficio +# MP C3502 supports PCL 6 as standard; Debian Trixie's compressed Foomatic +# database supplies this generic color PCL 6/PCL XL path without vendor code. + +QUEUE="Classroom_Color" +DRIVER="foomatic-db-compressed-ppds:0/ppd/foomatic-ppd/Generic-PCL_6_PCL_XL_Printer-pxlcolor.ppd" +MAKE_MODEL="Ricoh Aficio MP C3502 (Generic PCL 6 Color)" +LOCATION="Ekklesia Classroom" +MAKE_DEFAULT=true + +usage() { + cat <<'USAGE' +Usage: classroom-printer-setup [--no-default] PRINTER-IP-OR-URI + +Examples: + sudo classroom-printer-setup 10.20.30.40 + sudo classroom-printer-setup socket://printer.example.org:9100 + +An IP address or hostname uses JetDirect/AppSocket on port 9100. Explicit +socket://, ipp://, ipps:// and lpd:// URIs are also accepted. +USAGE +} + +log() { + echo "[classroom-printer-setup] $1" +} + +fail() { + log "ERROR: $1" >&2 + exit 1 +} + +if [[ "${CLASSROOM_PRINTER_SETUP_TESTING:-false}" != "true" && $EUID -ne 0 ]]; then + fail "run this command as root: sudo classroom-printer-setup " +fi + +if [[ "${1:-}" == "--no-default" ]]; then + MAKE_DEFAULT=false + shift +fi + +if [[ $# -ne 1 || "$1" == "-h" || "$1" == "--help" ]]; then + usage + [[ $# -eq 1 ]] && exit 0 + exit 2 +fi + +address="$1" + +case "$address" in + socket://*|ipp://*|ipps://*|lpd://*) + uri="$address" + ;; + *://*) + fail "unsupported printer URI scheme: $address" + ;; + *) + if [[ ! "$address" =~ ^[A-Za-z0-9._:-]+$ ]]; then + fail "printer address contains unsupported characters: $address" + fi + uri="socket://${address}:9100" + ;; +esac + +for command in lpadmin lpinfo lpstat cupsenable cupsaccept; do + command -v "$command" >/dev/null 2>&1 || \ + fail "$command is missing; reinstall the ekklesia-classroom package" +done + +if ! lpstat -r >/dev/null 2>&1; then + command -v systemctl >/dev/null 2>&1 && \ + systemctl start cups.service >/dev/null 2>&1 || true +fi + +lpstat -r >/dev/null 2>&1 || \ + fail "CUPS is not running; check: systemctl status cups" + +models="$(lpinfo -m)" || fail "CUPS could not list installed printer drivers" + +if ! grep -q "^${DRIVER} " <<< "$models"; then + fail "the Debian generic color PCL 6 driver is not installed" +fi + +log "configuring ${QUEUE} at ${uri}" + +# Repeating this command updates the same named queue; it never creates a +# second destination. None of these operations connects to the printer. +lpadmin \ + -p "$QUEUE" \ + -v "$uri" \ + -m "$DRIVER" \ + -D "$MAKE_MODEL" \ + -L "$LOCATION" || fail "CUPS could not configure ${QUEUE}" + +cupsenable "$QUEUE" || fail "CUPS could not enable ${QUEUE}" +cupsaccept "$QUEUE" || fail "CUPS could not accept jobs for ${QUEUE}" + +if [[ "$MAKE_DEFAULT" == true ]]; then + lpadmin -d "$QUEUE" || fail "CUPS could not make ${QUEUE} the default" +fi + +log "${QUEUE} is configured, enabled and accepting jobs." + +if [[ "$MAKE_DEFAULT" == true ]]; then + log "${QUEUE} is the system default printer." +fi + +log "The printer may remain offline until it is reachable; this does not affect boot." diff --git a/tests/test-classroom-config.sh b/tests/test-classroom-config.sh new file mode 100755 index 0000000..dd7ae55 --- /dev/null +++ b/tests/test-classroom-config.sh @@ -0,0 +1,104 @@ +#!/bin/bash +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +PYTHONPATH="$ROOT/lib" python3 - <<'PY' +import tempfile +from pathlib import Path + +from classroom_config import dock_items, ensure_device_config, read_device_config + +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 + + +print("1. dock capabilities") +cases = [ + ({}, ("browser",)), + ({"DVD_ENABLED": "true"}, ("browser", "dvd")), + ({"LIVE_ENABLED": "true"}, ("browser", "live")), + ( + {"DVD_ENABLED": "true", "LIVE_ENABLED": "true"}, + ("browser", "dvd", "live"), + ), +] + +for config, expected in cases: + actual = dock_items(config) + check(f"{config or 'both disabled'} -> {expected}", actual == expected, actual) + +check( + "false spellings do not leave placeholder items", + dock_items({"DVD_ENABLED": "false", "LIVE_ENABLED": "off"}) == ("browser",), +) +check( + "existing dock order is browser, DVD, LIVE", + dock_items({"DVD_ENABLED": "yes", "LIVE_ENABLED": "1"}) + == ("browser", "dvd", "live"), +) + +print() +print("2. fresh device configuration") +with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "etc" / "device.conf" + ensure_device_config(path) + config = read_device_config(path) + check("fresh config is created", path.is_file()) + check("DVD defaults off", config.get("DVD_ENABLED") == "false") + check("LIVE defaults off", config.get("LIVE_ENABLED") == "false") + check("MQTT defaults off", config.get("MQTT_ENABLED") == "false") + check("stable remains the fresh-install default", config.get("CHANNEL") == "stable") + +print() +print("3. upgrade from 0.1.36") +old = """# endpoint configuration from 0.1.36 +UPDATE_URL=https://updates.example.test +CHANNEL=canary +DVD_ENABLED=true +UI_SCALE=1.25 +MQTT_ENABLED=true +MQTT_HOST=broker.example.test +MQTT_PORT=1884 +MQTT_USERNAME=room +MQTT_PASSWORD=secret +CUSTOM_SETTING=keep-me +""" + +with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "device.conf" + path.write_text(old) + + ensure_device_config(path) + first = path.read_text() + config = read_device_config(path) + + check("old bytes remain at the start", first.startswith(old)) + check("channel survives", config.get("CHANNEL") == "canary") + check("DVD choice survives", config.get("DVD_ENABLED") == "true") + check("UI scale survives", config.get("UI_SCALE") == "1.25") + check("MQTT host survives", config.get("MQTT_HOST") == "broker.example.test") + check("MQTT password survives", config.get("MQTT_PASSWORD") == "secret") + check("unknown settings survive", config.get("CUSTOM_SETTING") == "keep-me") + check("LIVE is appended once", first.count("LIVE_ENABLED=") == 1) + + ensure_device_config(path) + second = path.read_text() + check("reinstall makes no further change", second == first) + check("LIVE still occurs once", second.count("LIVE_ENABLED=") == 1) + +print() +print(f"passed: {passed} failed: {failed}") +raise SystemExit(1 if failed else 0) +PY diff --git a/tests/test-classroom-printer-setup.sh b/tests/test-classroom-printer-setup.sh new file mode 100755 index 0000000..c4fca9d --- /dev/null +++ b/tests/test-classroom-printer-setup.sh @@ -0,0 +1,116 @@ +#!/bin/bash +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="$ROOT/scripts/classroom-printer-setup" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +passed=0 +failed=0 + +check() { + local label="$1" expected="$2" actual="$3" + if [[ "$actual" == *"$expected"* ]]; then + echo " ok $label" + passed=$((passed + 1)) + else + echo " FAIL $label" + echo " expected to contain: $expected" + echo " got: $actual" + failed=$((failed + 1)) + fi +} + +check_absent() { + local label="$1" unexpected="$2" actual="$3" + if [[ "$actual" != *"$unexpected"* ]]; then + echo " ok $label" + passed=$((passed + 1)) + else + echo " FAIL $label" + echo " expected not to contain: $unexpected" + echo " got: $actual" + failed=$((failed + 1)) + fi +} + +mkdir -p "$WORK/bin" + +for command in lpadmin cupsenable cupsaccept; do + cat > "$WORK/bin/$command" <<'SHIM' +#!/bin/bash +echo "$(basename "$0") $*" >> "$CUPS_LOG" +SHIM +done + +cat > "$WORK/bin/lpstat" <<'SHIM' +#!/bin/bash +[[ "${FAKE_CUPS_RUNNING:-true}" == "true" ]] +SHIM + +cat > "$WORK/bin/lpinfo" <<'SHIM' +#!/bin/bash +if [[ "${FAKE_DRIVER_PRESENT:-true}" == "true" ]]; then + echo "foomatic-db-compressed-ppds:0/ppd/foomatic-ppd/Generic-PCL_6_PCL_XL_Printer-pxlcolor.ppd Generic PCL 6/PCL XL Printer Foomatic/pxlcolor (recommended)" +else + echo "everywhere IPP Everywhere" +fi +SHIM + +chmod +x "$WORK/bin"/* +export PATH="$WORK/bin:$PATH" +export CUPS_LOG="$WORK/cups.log" +export CLASSROOM_PRINTER_SETUP_TESTING=true + +run() { + "$SCRIPT" "$@" 2>&1 +} + +echo "1. creates the explicit classroom queue" +: > "$CUPS_LOG" +out="$(run 10.20.30.40)" +log="$(cat "$CUPS_LOG")" +check "uses AppSocket by IP" "-v socket://10.20.30.40:9100" "$log" +check "uses Trixie's generic color PCL 6 driver" \ + "Generic-PCL_6_PCL_XL_Printer-pxlcolor.ppd" "$log" +check "uses one stable queue name" "lpadmin -p Classroom_Color" "$log" +check "enables the queue" "cupsenable Classroom_Color" "$log" +check "accepts jobs" "cupsaccept Classroom_Color" "$log" +check "defaults the queue" "lpadmin -d Classroom_Color" "$log" +check "reports offline-safe behavior" "may remain offline" "$out" + +echo +echo "2. repeated setup updates the same queue" +run 10.20.30.40 >/dev/null +configs="$(grep -c '^lpadmin -p Classroom_Color' "$CUPS_LOG")" +check "two runs configured the same queue twice" "2" "$configs" +check_absent "no generated queue suffix" "Classroom_Color_" "$(cat "$CUPS_LOG")" + +echo +echo "3. explicit URIs and non-default setup" +: > "$CUPS_LOG" +run --no-default ipp://printer.example.test/ipp/print >/dev/null +log="$(cat "$CUPS_LOG")" +check "keeps an explicit IPP URI" "-v ipp://printer.example.test/ipp/print" "$log" +check_absent "--no-default omits the default change" "lpadmin -d" "$log" + +echo +echo "4. errors are useful" +if out="$(run smb://printer/share)"; then + check "unsupported scheme fails" "failure" "unexpected success" +else + check "unsupported scheme is named" "unsupported printer URI scheme" "$out" +fi + +export FAKE_DRIVER_PRESENT=false +if out="$(run 10.20.30.40)"; then + check "missing driver fails" "failure" "unexpected success" +else + check "missing driver is explained" "generic color PCL 6 driver is not installed" "$out" +fi +unset FAKE_DRIVER_PRESENT + +echo +echo "passed: $passed failed: $failed" +[[ "$failed" -eq 0 ]] diff --git a/tests/test-deployment-support.sh b/tests/test-deployment-support.sh new file mode 100755 index 0000000..bcd2297 --- /dev/null +++ b/tests/test-deployment-support.sh @@ -0,0 +1,74 @@ +#!/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() +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() +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("printer setup is never a boot service", "classroom-printer-setup" not in units) +check("printer setup is never provisioning", "classroom-printer-setup" not in provision) +check("Chrome uses the system print dialog", policy.get("DisablePrintPreview") is True) + +print() +print("4. 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) + +print() +print(f"passed: {passed} failed: {failed}") +raise SystemExit(1 if failed else 0) +PY diff --git a/tools/build-release.sh b/tools/build-release.sh index b1cebb0..ba72bbf 100755 --- a/tools/build-release.sh +++ b/tools/build-release.sh @@ -145,6 +145,10 @@ install -Dm755 \ "$ROOT/scripts/classroom-provision" \ "$STAGE/usr/local/bin/classroom-provision" +install -Dm755 \ + "$ROOT/scripts/classroom-printer-setup" \ + "$STAGE/usr/local/bin/classroom-printer-setup" + # Shared helpers imported by the classroom commands. install -Dm644 \ "$ROOT/lib/classroom_config.py" \ diff --git a/tools/validate-trixie-release.sh b/tools/validate-trixie-release.sh new file mode 100755 index 0000000..5a70e88 --- /dev/null +++ b/tools/validate-trixie-release.sh @@ -0,0 +1,99 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "Usage: $0 CANDIDATE.deb [PREVIOUS.deb]" >&2 + exit 2 +fi + +candidate="$(realpath "$1")" +previous="" + +[[ -f "$candidate" ]] || { echo "Package not found: $candidate" >&2; exit 1; } + +if [[ $# -eq 2 ]]; then + previous="$(realpath "$2")" + [[ -f "$previous" ]] || { echo "Package not found: $previous" >&2; exit 1; } +fi + +echo "Validating $(basename "$candidate") on Debian Trixie" + +docker_args=( + run --rm -i + -v "$candidate:/packages/candidate.deb:ro" +) + +if [[ -n "$previous" ]]; then + docker_args+=( -v "$previous:/packages/previous.deb:ro" ) +fi + +docker "${docker_args[@]}" debian:trixie-slim \ + bash -s -- "${previous:+/packages/previous.deb}" <<'CONTAINER' +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq + +previous="${1:-}" + +if [[ -n "$previous" ]]; then + apt-get install -y -qq "$previous" >/tmp/previous-install.log + + cat > /etc/ekklesia-classroom/device.conf <<'CONFIG' +# Configuration carried forward from 0.1.36 +UPDATE_URL=https://updates.riverofglorymn.org +CHANNEL=canary +DVD_ENABLED=true +UI_SCALE=1.25 +MQTT_ENABLED=true +MQTT_HOST=broker.example.test +MQTT_PORT=1884 +MQTT_USERNAME=classroom +MQTT_PASSWORD=preserve-me +CUSTOM_SETTING=preserve-me-too +CONFIG +fi + +apt-get install -y -qq /packages/candidate.deb >/tmp/candidate-install.log + +command -v nmtui >/dev/null +command -v lpadmin >/dev/null +command -v lpinfo >/dev/null + +systemctl is-enabled NetworkManager.service >/dev/null +systemctl is-enabled cups.service >/dev/null +! systemctl is-enabled NetworkManager-wait-online.service >/dev/null 2>&1 + +grep -q '^managed=false$' /etc/NetworkManager/NetworkManager.conf + +# A normal Docker container has Ethernet and no Wi-Fi adapter. Reaching this +# point proves package configuration did not require Wi-Fi hardware. +! find /sys/class/net -mindepth 2 -maxdepth 2 -type d -name wireless | grep -q . + +/usr/sbin/cupsd +sleep 1 + +driver='foomatic-db-compressed-ppds:0/ppd/foomatic-ppd/Generic-PCL_6_PCL_XL_Printer-pxlcolor.ppd' +lpinfo -m | grep -q "^${driver} " + +classroom-printer-setup 192.0.2.10 >/tmp/printer-first.log +classroom-printer-setup 192.0.2.10 >/tmp/printer-second.log +lpstat -p Classroom_Color -d -v | grep -q 'socket://192.0.2.10:9100' + +if [[ -n "$previous" ]]; then + grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf + grep -q '^DVD_ENABLED=true$' /etc/ekklesia-classroom/device.conf + grep -q '^UI_SCALE=1.25$' /etc/ekklesia-classroom/device.conf + grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf + grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf + [[ "$(grep -c '^LIVE_ENABLED=' /etc/ekklesia-classroom/device.conf)" -eq 1 ]] + + apt-get install --reinstall -y -qq /packages/candidate.deb \ + >/tmp/candidate-reinstall.log + [[ "$(grep -c '^LIVE_ENABLED=' /etc/ekklesia-classroom/device.conf)" -eq 1 ]] +fi + +echo "Trixie package, upgrade, networking, printing and reinstall checks passed." +CONTAINER diff --git a/update_canary.sh b/update_canary.sh index 00caf8f..3bd6b94 100755 --- a/update_canary.sh +++ b/update_canary.sh @@ -22,6 +22,22 @@ echo "$ROOT/tools/build-release.sh" "$version" +# Validate the exact artifact that will be published. If a previous canary is +# available locally, exercise the real upgrade path as well as a fresh install. +validation_args=("$package_path") + +if [[ -f "$ROOT/repo/canary/manifest.json" ]]; then + previous_package_name="$(python3 -c \ + 'import json; print(json.load(open("repo/canary/manifest.json"))["package"].rsplit("/", 1)[-1])')" + previous_package="$ROOT/repo/packages/$previous_package_name" + + if [[ -f "$previous_package" && "$previous_package" != "$package_path" ]]; then + validation_args+=("$previous_package") + fi +fi + +"$ROOT/tools/validate-trixie-release.sh" "${validation_args[@]}" + hash="$(sha256sum "$package_path" | awk '{print $1}')" mkdir -p "$ROOT/repo/packages" "$ROOT/repo/canary"