#!/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 # 720s was enough while this package's heaviest dependency was Plasma. # 0.2.6 adds LibreOffice and a dozen font packages for Presentations, and # the run installs the PREVIOUS package as well as the candidate, so the # container now does two full desktop installs. Measured: the previous # install alone ran past 360s on the development VM and the whole run was # killed before it reached a single assertion. # # Generous rather than tight, on purpose. This is the gate that has to # say "the package is wrong", and a gate that says "the machine was busy" # instead teaches everybody to re-run it until it passes. timeout --signal=TERM --kill-after=15s 2700s \ docker "${docker_args[@]}" debian:trixie-slim \ bash -s -- "${previous:+/packages/previous.deb}" <<'CONTAINER' set -Eeuo pipefail validation_failure() { status=$? echo "Validation failed at line ${BASH_LINENO[0]}: ${BASH_COMMAND}" >&2 if command -v lpstat >/dev/null 2>&1; then lpstat -t >&2 || true lpoptions -p Elmer -l >&2 || true fi [[ ! -f /tmp/provision-first.log ]] || cat /tmp/provision-first.log >&2 [[ ! -f /tmp/provision-second.log ]] || cat /tmp/provision-second.log >&2 exit "$status" } trap validation_failure ERR export DEBIAN_FRONTEND=noninteractive # Package validation runs in a container with no init system. Do not let # maintainer scripts try to start CUPS, Avahi or other irrelevant daemons; # services are inspected and CUPS is started explicitly when the test needs it. cat > /usr/sbin/policy-rc.d <<'POLICY' #!/bin/sh exit 101 POLICY chmod 0755 /usr/sbin/policy-rc.d run_logged() { label="$1" seconds="$2" shift 2 if ! timeout --signal=TERM --kill-after=10s "${seconds}s" "$@" \ >"/tmp/${label}.log" 2>&1; then cat "/tmp/${label}.log" >&2 echo "${label} exceeded ${seconds}s or failed" >&2 exit 1 fi } run_logged apt-update 300 apt-get update -qq previous="${1:-}" if [[ -n "$previous" ]]; then run_logged previous-install 1200 apt-get install -y -qq "$previous" cat > /etc/ekklesia-classroom/device.conf <<'CONFIG' # Configuration carried forward from 0.1.36 UPDATE_URL=https://updates.riverofglorymn.org CHANNEL=canary DVD_ENABLED=false 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 run_logged candidate-install 1200 apt-get install -y -qq /packages/candidate.deb 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 # The audio policy reaches a session, and is ordered after the server it # actually talks to. Ordering it only after pipewire and wireplumber is # what let it race PipeWire's PulseAudio server at boot, fail its one # startup attempt and then sit idle — the rooms were silent on 0.2.2 for # exactly this reason, so the ordering is asserted rather than assumed. audio_unit=/etc/systemd/user/ekklesia-classroom-audio.service [[ -f "$audio_unit" ]] grep -q 'pipewire-pulse' "$audio_unit" [[ -L /etc/systemd/user/plasma-workspace.target.wants/ekklesia-classroom-audio.service ]] command -v classroom-audio >/dev/null command -v pactl >/dev/null # The classroom account cannot be shown a saved Wi-Fi password. The rule # has to be installed, owned by root — polkit silently ignores a rules # file that is not — and it has to refuse the action Debian ships as # "yes", which is the one a teacher would actually reach. network_rules=/usr/share/polkit-1/rules.d/49-ekklesia-classroom-network.rules [[ -f "$network_rules" ]] [[ "$(stat -c %U:%G "$network_rules")" == "root:root" ]] grep -q "settings.modify.own" "$network_rules" grep -q "wifi.share.protected" "$network_rules" # And it must NOT refuse the actions that keep the room on the network. ! grep -qE '"org\.freedesktop\.NetworkManager\.network-control"' "$network_rules" grep -q "kcm_networkmanagement=false" /etc/ekklesia-classroom/xdg/kdeglobals command -v classroom-wifi >/dev/null # Adopting an installer-configured Wi-Fi is an administrator's command, # never something an upgrade does on its own: it moves a machine's only # link between managers, and the machine it would strand is a laptop in a # locked room. The migration may only LOOK. classroom-wifi --check 2>&1 | grep -q "no wireless interface" classroom-wifi --adopt 2>&1 | grep -q "nothing to adopt" ! grep -qE '^[^#]*classroom-wifi --adopt' /usr/local/bin/classroom-migrate grep -q "classroom-wifi --check" /usr/local/bin/classroom-migrate PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 -c \ 'import classroom_network; classroom_network.parse("")' # THE 0.2.5 WI-FI REGRESSION, ASSERTED ON A REAL INSTALL. # # The prayer-room laptop's network is hidden and its name has a space in # it. The migration dropped the hidden flag, so the connection it built # could never associate. Checked against the module the package actually # installed, not the one in a checkout. PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 <<'WIFI_CHECK' import classroom_network as n stanza = n.wireless(n.parse( 'allow-hotplug wlp2s0\n' 'iface wlp2s0 inet dhcp\n' '\twpa-ssid "ROGC Classroom"\n' '\twpa-psk se#cret\n' '\twpa-scan-ssid 1\n' ), lambda device: True)[0] found = n.profile(stanza) assert found["ssid"] == "ROGC Classroom", found assert found["hidden"] is True, "the hidden flag was dropped again" assert found["psk"] == "se#cret", "a # in the password was truncated again" assert found["key_mgmt"] == "wpa-psk", found # A rollback puts back exactly what it commented out, and nothing else. before = ("auto enp1s0\niface enp1s0 inet static\n\taddress 10.40.0.9/24\n\n" "allow-hotplug wlp2s0\niface wlp2s0 inet dhcp\n\twpa-ssid Sanctuary\n") after = n.comment_out(before, n.wireless(n.parse(before), lambda d: d == "wlp2s0")[0]) restored, count = n.uncomment(after) assert count, "nothing was marked, so a rollback would have nothing to undo" assert restored == before, "the rollback did not restore the file exactly" WIFI_CHECK # And the command itself carries both halves of the fix. grep -q '802-11-wireless.hidden' /usr/local/bin/classroom-wifi grep -q 'managed", "yes"' /usr/local/bin/classroom-wifi # PRESENTATIONS. # # Off by default, with everything it needs present on every machine, so # enabling it on a device is one line in device.conf and a reboot. command -v classroom-presentations >/dev/null command -v classroom-presentation-import >/dev/null command -v soffice >/dev/null command -v pdftoppm >/dev/null command -v pdfinfo >/dev/null [[ -f /etc/systemd/user/ekklesia-classroom-presentations.service ]] # Enabled the same way the audio policy is, so it actually reaches a # session rather than merely being installed. A unit file nobody starts # is a teacher whose download never appears. [[ -L /etc/systemd/user/plasma-workspace.target.wants/ekklesia-classroom-presentations.service ]] [[ -f /usr/share/applications/ekklesia-presentations.desktop ]] [[ -f /usr/share/icons/hicolor/scalable/apps/ekklesia-presentations.svg ]] # A classroom that has never heard of the setting comes up without it. PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 -c \ 'from classroom_config import feature; assert feature({}, "PRESENTATIONS_ENABLED") is False' # Disabled is the default, so both commands decline to run and say why. classroom-presentations 2>&1 | grep -q 'PRESENTATIONS_ENABLED is false' classroom-presentation-import --once 2>&1 | grep -q 'PRESENTATIONS_ENABLED is false' # THE CONVERSION THIS WHOLE FEATURE RESTS ON. # # On a real Debian, with the fonts this package installs. No window, no # splash, no first-run dialog, no editor — measured rather than assumed, # because "LibreOffice is installed" is not the same claim as "a PPTX # becomes slides a teacher can be shown". mkdir -p /tmp/deck python3 <<'MAKE_DECK' page = ('' '%s') open("/tmp/deck/deck.fodp", "w").write( '' '' '' + (page % (1, "Wednesday Bible Study")) + (page % (2, "Session 7")) + '') MAKE_DECK HOME=/tmp/deck timeout 240 soffice --headless --norestore --nologo \ --convert-to pptx --outdir /tmp/deck /tmp/deck/deck.fodp >/dev/null 2>&1 [[ -f /tmp/deck/deck.pptx ]] PYTHONPATH=/usr/local/lib/ekklesia-classroom HOME=/tmp/deck python3 <<'CONVERT_CHECK' import pathlib import classroom_presentations as library ok, error = library.convert_to_pdf("/tmp/deck/deck.pptx", "/tmp/deck/slides.pdf") assert ok, f"PPTX could not be converted: {error}" pages = library.page_count("/tmp/deck/slides.pdf") assert pages == 2, f"expected 2 slides, got {pages}" made, error = library.render_pages("/tmp/deck/slides.pdf", "/tmp/deck/pages") assert made == 2, f"expected 2 rendered pages, got {made}: {error}" # The library asks for page-001.png whatever the page count's width is. for number in (1, 2): path = pathlib.Path(f"/tmp/deck/pages/page-{number:03d}.png") assert path.exists(), f"{path} was not rendered" assert path.stat().st_size > 0, f"{path} is empty" # Slide 2 is the one a teacher picks as the card picture, so its text has # to have survived the conversion. text = library.first_text("/tmp/deck/slides.pdf", pages=2) assert "Session 7" in text, f"text did not survive the conversion: {text!r}" CONVERT_CHECK # The fonts a Google Slides export asks for, as files on disk. # # Asserted by path rather than through fc-list: fc-list belongs to the # fontconfig package, which nothing here pulls in, so the check passed # vacuously until it didn't and then failed for a reason that had nothing # to do with the fonts. What LibreOffice actually needs is the file. [[ -f /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf ]] [[ -f /usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf ]] [[ -f /usr/share/fonts/truetype/liberation/LiberationSansNarrow-Regular.ttf ]] [[ -f /usr/share/fonts/truetype/crosextra/Carlito-Regular.ttf ]] [[ -f /usr/share/fonts/truetype/crosextra/Caladea-Regular.ttf ]] [[ -f /usr/share/fonts/truetype/croscore/Arimo-Regular.ttf ]] # And the one that must NOT be here, because it prompts for a licence. ! dpkg -l ttf-mscorefonts-installer 2>/dev/null | grep -q '^ii' # THE MANUAL. # # Installed on every machine, works with no network at all, and shows # only the features this device has. [[ -f /usr/local/share/ekklesia-classroom/manual/classroom-manual.html ]] [[ -f /usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf ]] command -v classroom-manual >/dev/null [[ -f /usr/share/applications/ekklesia-manual.desktop ]] head -c 5 /usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf \ | grep -q '%PDF-' grep -aq 'Revision' /usr/local/share/ekklesia-classroom/manual/classroom-manual.pdf ! grep -qE 'https?://' \ /usr/local/share/ekklesia-classroom/manual/classroom-manual.html # The manifest the manual reads carries feature NAMES and nothing else: # no broker password, no addresses, nothing else out of device.conf. printf 'PRESENTATIONS_ENABLED=true\nWONDERINK_ENABLED=false\nMQTT_PASSWORD=never-a-teacher\n' \ >> /etc/ekklesia-classroom/device.conf classroom-desktop-layout >/dev/null 2>&1 || true [[ -f /run/ekklesia-classroom/features.json ]] grep -q 'PRESENTATIONS_ENABLED' /run/ekklesia-classroom/features.json ! grep -q 'WONDERINK_ENABLED' /run/ekklesia-classroom/features.json ! grep -q 'never-a-teacher' /run/ekklesia-classroom/features.json ! grep -q '10.40.0.2' /run/ekklesia-classroom/features.json classroom-manual --features | grep -qE 'PRESENTATIONS_ENABLED +shown' classroom-manual --features | grep -qE 'WONDERINK_ENABLED +hidden' # The panel offers Presentations only where it is enabled, and the three # applications that were already on the bar keep their order. layout=/usr/share/plasma/look-and-feel/org.ekklesia.classroom.desktop/contents/layouts/org.kde.plasma.desktop-layout.js grep -q 'var PRESENTATIONS = true;' "$layout" grep -q 'ekklesia-presentations.desktop' "$layout" grep -q 'ekklesia-manual.desktop' "$layout" python3 - "$layout" <<'ORDER_CHECK' import re import sys text = open(sys.argv[1]).read() order = re.findall(r'launchers\.push\("applications:([^"]+)"\)', text) expected = ["ekklesia-wonderink.desktop", "google-chrome.desktop", "ekklesia-dvd.desktop", "ekklesia-presentations.desktop"] assert order == expected, f"the dock order changed: {order}" ORDER_CHECK sed -i '/^PRESENTATIONS_ENABLED=true$/d;/^WONDERINK_ENABLED=false$/d;/^MQTT_PASSWORD=never-a-teacher$/d' \ /etc/ekklesia-classroom/device.conf # The policy module and the daemon agree on what they import, on a machine # rather than in a checkout. PYTHONPATH=/usr/local/lib/ekklesia-classroom python3 -c \ 'import classroom_audio; classroom_audio.settled([], "auto", None)' 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} " # Simulate the queue an older release may have left behind, then run the real # boot provisioning path. Chrome is stubbed as already installed and DVD is # disabled so the test remains entirely local and deterministic. lpadmin -p Classroom_Color -v socket://192.0.2.10:9100 -m "$driver" install -m755 /bin/true /usr/local/bin/google-chrome timeout --signal=TERM --kill-after=5s 45s classroom-provision \ >/tmp/provision-first.log ! lpstat -p Classroom_Color >/dev/null 2>&1 lpstat -p Elmer -d -v | grep -q 'socket://10.10.0.155:9100' lpstat -l -p Elmer | grep -q 'Description: Elmer' lpoptions -p Elmer -l | grep -Eq '^PrintoutMode/.*:.*\*Normal([[:space:]]|$)' lpoptions -p Elmer -l | grep -Eq '^ColorModel/.*:.*\*Color([[:space:]]|$)' [[ "$(lpstat -e)" == "Elmer" ]] [[ "$(systemctl is-enabled avahi-daemon.service 2>/dev/null || true)" == "masked" ]] [[ "$(systemctl is-enabled avahi-daemon.socket 2>/dev/null || true)" == "masked" ]] timeout --signal=TERM --kill-after=5s 45s classroom-provision \ >/tmp/provision-second.log grep -q 'Elmer is already correctly configured' /tmp/provision-second.log if [[ -n "$previous" ]]; then # The room's own values, untouched by the upgrade. grep -q '^DVD_ENABLED=false$' /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 # The channel a room was put on deliberately survives the upgrade. # canary is where the next development release goes, and the Plasma # classroom is the stable line now, so nothing here has to move. grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf # And a room that was carried through the experiment on its own # channel comes home, because that channel is the obsolete one now. sed -i 's/^CHANNEL=.*/CHANNEL=classroom-v2/' /etc/ekklesia-classroom/device.conf classroom-migrate >/tmp/migrate-from-v2.log 2>&1 grep -q '^CHANNEL=stable$' /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 sed -i 's/^CHANNEL=.*/CHANNEL=canary/' /etc/ekklesia-classroom/device.conf # Every setting this release has that the room predates, appended # exactly once. classroom-migrate hands device.conf to the shared # helper after its own work for this, so that a machine converges # whether it got here through this postinst or through somebody # running the command by hand. for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE; do [[ "$(grep -c "^${setting}=" /etc/ekklesia-classroom/device.conf)" -eq 1 ]] done run_logged candidate-reinstall 900 \ apt-get install --reinstall -y -qq /packages/candidate.deb # An install that changes nothing must change nothing. for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE; do [[ "$(grep -c "^${setting}=" /etc/ekklesia-classroom/device.conf)" -eq 1 ]] done grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf grep -q '^CUSTOM_SETTING=preserve-me-too$' /etc/ekklesia-classroom/device.conf grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf # And by hand, on a machine that is already converged: the command a # technician runs must be safe to run twice. classroom-migrate >/tmp/migrate-by-hand.log 2>&1 for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE; do [[ "$(grep -c "^${setting}=" /etc/ekklesia-classroom/device.conf)" -eq 1 ]] done grep -q '^MQTT_PASSWORD=preserve-me$' /etc/ekklesia-classroom/device.conf ! grep -q 'preserve-me' /tmp/migrate-by-hand.log fi echo "Trixie package, upgrade, bounded install, networking, Elmer printing and reinstall checks passed." CONTAINER