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]>
230 lines
7.4 KiB
Bash
Executable File
230 lines
7.4 KiB
Bash
Executable File
#!/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])
|
|
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',
|
|
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, 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)
|
|
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,
|
|
)
|
|
|
|
if shared:
|
|
ensure_device_config(config)
|
|
|
|
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("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)
|
|
PY
|