#!/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
timeout --signal=TERM --kill-after=15s 720s \
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 120 apt-get update -qq
previous="${1:-}"
if [[ -n "$previous" ]]; then
run_logged previous-install 360 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 360 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, by their real names.
fc-list 2>/dev/null | grep -qi 'Liberation Sans'
fc-list 2>/dev/null | grep -qi 'Carlito'
fc-list 2>/dev/null | grep -qi 'Caladea'
# 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 360 \
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