#!/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