fix: let the migration hand device.conf to the one place defaults live

classroom-migrate knew how to remove a setting, move a channel and point
a room at the broker, and knew nothing about which settings the classroom
has gained since a room was installed. That list is in
classroom_config.py and it stays there: the migration now hands the file
to ensure_device_config when its own work is done, rather than growing a
second copy of it.

It matters for the command rather than the package. postinst already
called the shared helper before running the migration, so an install
converged; a technician running `sudo classroom-migrate` by hand on an
old machine got a file that was still short of LIVE_ENABLED,
AUDIO_OUTPUT and UI_SCALE. Both paths end in the same place now, and the
release gate runs the command by hand on an already-converged machine to
prove it.

The validator's own expectation was the release-blocking failure, and it
was not what it looked like: it stops at CHANNEL=canary, before it ever
reaches the LIVE_ENABLED count. canary is the sway line, and moving a
migrated room off it is deliberate — a Plasma room left there is handed
the old desktop back by the next release published to it. The fixture now
expects the move, checks that every setting this release has arrived
exactly once, and checks it again after a reinstall.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-13 07:46:08 -05:00
co-authored by Claude Opus 5
parent 34fd02e08f
commit bd55c5abd9
4 changed files with 165 additions and 5 deletions
+42
View File
@@ -25,6 +25,15 @@ set -uo pipefail
CLASSROOM_USER=classroom
DEVICE_CONFIG=/etc/ekklesia-classroom/device.conf
# The classroom's own Python, wherever this is being run from: the package
# path first, and the checkout next to this script when there is no
# package — which is how the tests reach it.
LIB_DIR=/usr/local/lib/ekklesia-classroom
if [[ ! -r "$LIB_DIR/classroom_config.py" ]]; then
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" 2>/dev/null && pwd || true)"
fi
# The shells this appliance replaces. There is exactly one desktop stack
# now — SDDM, KWin, plasmashell — and every one of these is a component of
# a stack that is gone.
@@ -260,6 +269,39 @@ if changed:
PY
fi
# --------------------------------------------- and then the shared defaults
# Everything above is this package's own business: settings to remove, a
# channel to move, a broker to point at. What it deliberately does not do
# is know which settings the classroom has gained since a room was
# installed. That list lives in classroom_config.py, in one place, and
# ensure_device_config appends exactly the ones a file is missing and
# nothing else — so a room upgraded from any age ends up with all of them,
# whether it arrived here through the package's postinst or through
# somebody running this command by hand. Repeating the list here would
# mean two places to forget.
#
# It runs last on purpose. The migration above rewrites lines; this only
# ever appends, so appending afterwards cannot be undone by it.
#
# Outside the guard the migration is inside, because this also writes a
# device.conf for a machine that has none.
if [[ -n "$LIB_DIR" && -r "$LIB_DIR/classroom_config.py" ]]; then
if ! PYTHONPATH="$LIB_DIR" python3 - "$DEVICE_CONFIG" <<'SHARED'
import sys
from classroom_config import ensure_device_config
ensure_device_config(sys.argv[1])
SHARED
then
say "WARNING: could not add this release's settings to device.conf"
fi
else
say "WARNING: classroom_config.py is missing, so device.conf may be short"
say " of settings this release added"
fi
# ------------------------------------------------------- the old session
# Plasma reads the classroom's settings out of a directory that has to be
+78 -2
View File
@@ -20,6 +20,13 @@ import tempfile
from pathlib import Path
root = Path(sys.argv[1])
sys.path.insert(0, str(root / "lib"))
from classroom_config import ( # noqa: E402
DEVICE_CONFIG_SECTIONS,
ensure_device_config,
)
script = (root / "scripts/classroom-migrate").read_text()
block = re.search(r'python3 - "\$DEVICE_CONFIG" <<\'PY\'\n(.*?)\nPY\n',
@@ -46,8 +53,12 @@ if block is None:
migration = block.group(1)
def migrate(text):
"""Run the real migration over a device.conf and return it."""
def migrate(text, shared=False):
"""Run the real migration over a device.conf and return it.
With shared=True, the shared helper runs afterwards as well, which is
the whole sequence classroom-migrate performs.
"""
with tempfile.TemporaryDirectory() as directory:
source = Path(directory) / "migrate.py"
source.write_text(migration)
@@ -59,6 +70,9 @@ def migrate(text):
capture_output=True, text=True, check=False,
)
if shared:
ensure_device_config(config)
return config.read_text(), result.stdout + result.stderr
@@ -147,6 +161,68 @@ once, _ = migrate(BEFORE)
twice, _ = migrate(once)
check("the second run is a no-op", once == twice)
print()
print("5. a room old enough to be missing settings this release has")
# The migration knows nothing about which settings the classroom has
# gained since a room was installed, and must not: that list lives in
# classroom_config.py. What it has to do is hand the file to the shared
# helper afterwards, so a machine converges whether it arrived through the
# package's postinst or through somebody running the command by hand.
AGED = """# A room set up a long time ago. Keep this comment.
UPDATE_URL=https://updates.riverofglorymn.org
CHANNEL=a-channel-somebody-named
COMPOSITOR=hyprland
MQTT_ENABLED=false
MQTT_HOST=
MQTT_USERNAME=classroom
MQTT_PASSWORD=preserve-me
CUSTOM_SETTING=preserve-me-too
"""
after, output = migrate(AGED, shared=True)
missing = [key for key, _ in DEVICE_CONFIG_SECTIONS]
check("there are settings for this to be about", len(missing) >= 3)
for key in ("LIVE_ENABLED", "AUDIO_OUTPUT", "UI_SCALE"):
check(f"{key} is appended", f"\n{key}=" in "\n" + after)
check(f"{key} exactly once",
len(re.findall(rf"^{key}=", after, re.M)) == 1)
check("every setting this release knows about is present",
all(re.search(rf"^{key}=", after, re.M) for key in missing))
check("the room's comment survives",
"# A room set up a long time ago. Keep this comment." in after)
check("the custom setting survives", "CUSTOM_SETTING=preserve-me-too" in after)
check("the password survives", "MQTT_PASSWORD=preserve-me" in after)
check("and is still never printed", "preserve-me" not in output)
check("the username survives", "MQTT_USERNAME=classroom" in after)
check("the deliberate channel is still deliberate",
"CHANNEL=a-channel-somebody-named" in after)
check("the migration still did its own work",
"COMPOSITOR" not in after
and "MQTT_ENABLED=true" in after
and "MQTT_HOST=10.40.0.2" in after)
twice, _ = migrate(after, shared=True)
check("and running the whole thing again changes nothing", twice == after)
print()
print("6. the defaults live in one place")
check("the migration does not carry a copy of them",
not any(key in migration
for key, _ in DEVICE_CONFIG_SECTIONS
if key not in ("MQTT_ENABLED",)))
check("the script calls the shared helper",
"ensure_device_config" in script)
check("after its own migration, not before",
script.index("ensure_device_config")
> script.index("OBSOLETE_CHANNELS"))
check("with the file it was given",
"ensure_device_config(sys.argv[1])" in script)
print()
print(f"{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)
+12
View File
@@ -211,6 +211,18 @@ 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)
# The gate checks convergence, not just that the package installs: an
# upgraded room ends on the v2 channel with every setting this release
# has, exactly once, and the command a technician runs is safe to run on
# a machine that is already converged.
check("the gate checks the upgraded channel",
"'^CHANNEL=classroom-v2$'" in validation_script)
check("and that every new setting arrived exactly once",
"for setting in LIVE_ENABLED AUDIO_OUTPUT UI_SCALE" in validation_script)
check("and that running the migration by hand is safe",
"classroom-migrate >/tmp/migrate-by-hand.log" in validation_script)
check("and that it never prints the broker password",
"! grep -q 'preserve-me' /tmp/migrate-by-hand.log" in validation_script)
print()
print("7. one scale, applied everywhere")
+33 -3
View File
@@ -141,16 +141,46 @@ timeout --signal=TERM --kill-after=5s 45s classroom-provision \
grep -q 'Elmer is already correctly configured' /tmp/provision-second.log
if [[ -n "$previous" ]]; then
grep -q '^CHANNEL=canary$' /etc/ekklesia-classroom/device.conf
# 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
[[ "$(grep -c '^LIVE_ENABLED=' /etc/ekklesia-classroom/device.conf)" -eq 1 ]]
# And the one value the upgrade is supposed to change. canary is the
# sway line: a room that has just taken the Plasma package and is left
# pointed at it is handed the old desktop back by the next release
# there, which is the upgrade undoing itself.
grep -q '^CHANNEL=classroom-v2$' /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
[[ "$(grep -c '^LIVE_ENABLED=' /etc/ekklesia-classroom/device.conf)" -eq 1 ]]
# 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=classroom-v2$' /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."