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]>
318 lines
12 KiB
Bash
Executable File
318 lines
12 KiB
Bash
Executable File
#!/bin/bash
|
|
set -uo pipefail
|
|
|
|
# Turn a machine that was something else into this classroom.
|
|
#
|
|
# A room does not arrive new. It arrives as whatever it was before: the
|
|
# sway classroom, the Hyprland experiment, a machine a technician tried
|
|
# something on. The package can describe the desktop it wants perfectly
|
|
# and still lose to one leftover symlink, which is what happened: a real
|
|
# room upgraded to the Plasma appliance and came up with the old Waybar
|
|
# across the top, mako still catching notifications, and Plasma's own
|
|
# stock panel underneath.
|
|
#
|
|
# So the installation converges the machine instead of assuming it.
|
|
# Everything here is something a classroom package created, or state this
|
|
# package can identify as belonging to a classroom shell it replaces.
|
|
# Nothing removes a file Debian owns: the old bar and the old notification
|
|
# daemon stay installed and simply stop being started.
|
|
#
|
|
# Idempotent, and safe on a machine that has none of this. Run by hand at
|
|
# any time:
|
|
#
|
|
# sudo classroom-migrate
|
|
|
|
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.
|
|
LEGACY_UNITS=(waybar.service mako.service swayidle.service swaybg.service)
|
|
LEGACY_PROCESSES=(waybar mako classroom-dock swaybg swayidle)
|
|
LEGACY_EXEC_PATTERN='waybar|mako|classroom-dock|classroom-notifications|sway|Hyprland|start-hyprland|classroom-session'
|
|
|
|
say() {
|
|
echo "[classroom-migrate] $1"
|
|
}
|
|
|
|
if [[ "$(id -u)" -ne 0 ]]; then
|
|
say "must be run as root"
|
|
exit 1
|
|
fi
|
|
|
|
home=$(getent passwd "$CLASSROOM_USER" | cut -d: -f6)
|
|
uid=$(id -u "$CLASSROOM_USER" 2>/dev/null || true)
|
|
|
|
# ---------------------------------------------------------------- units
|
|
|
|
# Stop what is running now. The upgrade happens underneath a live session,
|
|
# so the old bar is on the screen while this runs.
|
|
if [[ -n "$uid" && -d "/run/user/$uid" ]]; then
|
|
for unit in "${LEGACY_UNITS[@]}"; do
|
|
runuser -u "$CLASSROOM_USER" -- env "XDG_RUNTIME_DIR=/run/user/$uid" \
|
|
systemctl --user stop "$unit" >/dev/null 2>&1 || true
|
|
done
|
|
fi
|
|
|
|
for process in "${LEGACY_PROCESSES[@]}"; do
|
|
if pkill -u "$CLASSROOM_USER" -x "$process" 2>/dev/null; then
|
|
say "stopped $process"
|
|
fi
|
|
done
|
|
|
|
# Remove the activation, wherever it was made. `systemctl --global enable`
|
|
# writes into /etc/systemd/user; a session that set XDG_CONFIG_DIRS gets
|
|
# /etc/xdg/systemd/user as well; and a unit enabled by hand as the
|
|
# classroom user lands in the home directory. The real room had it in the
|
|
# second of those, which is why guessing one location would not have been
|
|
# enough.
|
|
wants_roots=(/etc/systemd/user /etc/xdg/systemd/user /run/systemd/user)
|
|
[[ -n "$home" ]] && wants_roots+=("$home/.config/systemd/user")
|
|
|
|
for root in "${wants_roots[@]}"; do
|
|
[[ -d "$root" ]] || continue
|
|
|
|
for unit in "${LEGACY_UNITS[@]}"; do
|
|
while IFS= read -r link; do
|
|
rm -f "$link"
|
|
say "removed activation ${link#/}"
|
|
done < <(find "$root" -name "$unit" -path '*.wants/*' 2>/dev/null)
|
|
done
|
|
done
|
|
|
|
# The unit files themselves, where somebody wrote them by hand. Neither
|
|
# waybar nor mako ships a systemd unit in trixie, so a file by one of
|
|
# those names belongs to the shell this package replaces — but the rule is
|
|
# checked rather than assumed: anything a Debian package owns is left
|
|
# exactly where it is.
|
|
for root in /etc/systemd/user /etc/xdg/systemd/user "${home:+$home/.config/systemd/user}"; do
|
|
[[ -n "$root" && -d "$root" ]] || continue
|
|
|
|
for unit in "${LEGACY_UNITS[@]}"; do
|
|
[[ -f "$root/$unit" ]] || continue
|
|
|
|
if dpkg -S "$root/$unit" >/dev/null 2>&1; then
|
|
say "left $root/$unit alone: a Debian package owns it"
|
|
continue
|
|
fi
|
|
|
|
rm -f "$root/$unit"
|
|
say "removed the hand-written $root/$unit"
|
|
done
|
|
done
|
|
|
|
# And then say no once more, at the system level, so that a unit file
|
|
# reappearing from anywhere cannot start it. A mask is a symlink to
|
|
# /dev/null in /etc/systemd/user: visible, reversible, and it removes
|
|
# nothing.
|
|
systemctl --global mask "${LEGACY_UNITS[@]}" >/dev/null 2>&1 || true
|
|
|
|
# And the other way the old notification daemon starts: D-Bus activation.
|
|
# The package shadows mako's service file so that activation fails, but a
|
|
# session bus that is already running read its service directories at
|
|
# startup and has not seen it. Measured during crash recovery on a
|
|
# migrated machine: the panel was killed, a notification was sent while
|
|
# nothing owned the name, and mako took it.
|
|
if [[ -n "$uid" && -S "/run/user/$uid/bus" ]]; then
|
|
runuser -u "$CLASSROOM_USER" -- env \
|
|
"XDG_RUNTIME_DIR=/run/user/$uid" \
|
|
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$uid/bus" \
|
|
dbus-send --session --dest=org.freedesktop.DBus --type=method_call \
|
|
/org/freedesktop/DBus org.freedesktop.DBus.ReloadConfig \
|
|
>/dev/null 2>&1 || true
|
|
fi
|
|
|
|
# ------------------------------------------------------------- autostart
|
|
|
|
# XDG autostart entries that start a component of the old shell. Matched on
|
|
# what they run, not on their filename, and only in the classroom's own
|
|
# directory.
|
|
if [[ -n "$home" && -d "$home/.config/autostart" ]]; then
|
|
for entry in "$home/.config/autostart"/*.desktop; do
|
|
[[ -e "$entry" ]] || continue
|
|
|
|
if grep -qE "^Exec=.*($LEGACY_EXEC_PATTERN)" "$entry" 2>/dev/null; then
|
|
rm -f "$entry"
|
|
say "removed autostart entry $(basename "$entry")"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# ------------------------------------------------------------ tty1 login
|
|
|
|
# The sway classroom started its session from the login shell of an
|
|
# autologin getty. SDDM owns the session now, and two things owning tty1
|
|
# is how a machine ends up with no usable screen at all.
|
|
if [[ -n "$home" && -f "$home/.bash_profile" ]]; then
|
|
if grep -qE "exec .*($LEGACY_EXEC_PATTERN)" "$home/.bash_profile" 2>/dev/null; then
|
|
rm -f "$home/.bash_profile"
|
|
say "removed the login shell that used to start the old session"
|
|
fi
|
|
fi
|
|
|
|
rm -f /etc/systemd/system/[email protected]/autologin.conf
|
|
rmdir --ignore-fail-on-non-empty \
|
|
/etc/systemd/system/[email protected] 2>/dev/null || true
|
|
|
|
# --------------------------------------------------------- device.conf
|
|
|
|
# The room's own settings file outlives every version of this package, so
|
|
# it is edited rather than replaced: the broker password is in it.
|
|
#
|
|
# COMPOSITOR chose between sway and Hyprland. There is nothing left for it
|
|
# to choose, and a stale value is worse than none — it is an instruction
|
|
# this package cannot carry out.
|
|
#
|
|
# CHANNEL decides which manifest the updater fetches. A machine left on the
|
|
# hyprland channel would be handed the Hyprland experiment again by the
|
|
# next release published there, undoing the upgrade it just took.
|
|
#
|
|
# The broker is the same for every classroom, so a room that was set up
|
|
# before there was one — blank address, switched off — is given the
|
|
# address rather than left with Parent Call saying there is no church
|
|
# network. A room already pointed somewhere else is left pointed there:
|
|
# that is a decision somebody made. The username and the password are
|
|
# never read, never written and never printed.
|
|
if [[ -f "$DEVICE_CONFIG" ]]; then
|
|
python3 - "$DEVICE_CONFIG" <<'PY'
|
|
import re
|
|
import sys
|
|
|
|
OBSOLETE_KEYS = ("COMPOSITOR", "HYPR_APPEARANCE")
|
|
# Every channel that serves a desktop this package replaces. A machine
|
|
# that has just installed the Plasma classroom must not be left pointed at
|
|
# a line that will hand it the sway build back at the next release: stable
|
|
# and canary both would. A room already on the v2 channel, or on one
|
|
# somebody named deliberately, is left where it is.
|
|
OBSOLETE_CHANNELS = {"stable", "canary", "hyprland"}
|
|
CHANNEL = "classroom-v2"
|
|
|
|
# The church broker. One address for every room.
|
|
BROKER_HOST = "10.40.0.2"
|
|
BROKER_PORT = "1883"
|
|
TRUTHY = {"1", "true", "yes", "on", "enabled"}
|
|
|
|
path = sys.argv[1]
|
|
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
lines = handle.readlines()
|
|
|
|
out = []
|
|
changed = []
|
|
seen = set()
|
|
|
|
|
|
def value_of(line):
|
|
return line.split("=", 1)[1].strip()
|
|
|
|
|
|
for line in lines:
|
|
key = line.split("=", 1)[0].strip() if "=" in line else ""
|
|
|
|
if key in OBSOLETE_KEYS:
|
|
changed.append(f"dropped {key}")
|
|
continue
|
|
|
|
if key:
|
|
seen.add(key)
|
|
|
|
if key == "CHANNEL":
|
|
value = value_of(line)
|
|
|
|
if value.lower() in OBSOLETE_CHANNELS:
|
|
changed.append(f"moved off the {value} channel onto {CHANNEL}")
|
|
line = f"CHANNEL={CHANNEL}\n"
|
|
|
|
elif key == "MQTT_ENABLED":
|
|
if value_of(line).lower() not in TRUTHY:
|
|
changed.append("switched the broker on")
|
|
line = "MQTT_ENABLED=true\n"
|
|
|
|
elif key == "MQTT_HOST":
|
|
if not value_of(line):
|
|
changed.append(f"filled in the broker address {BROKER_HOST}")
|
|
line = f"MQTT_HOST={BROKER_HOST}\n"
|
|
|
|
elif key == "MQTT_PORT":
|
|
if not value_of(line):
|
|
changed.append(f"filled in the broker port {BROKER_PORT}")
|
|
line = f"MQTT_PORT={BROKER_PORT}\n"
|
|
|
|
out.append(line)
|
|
|
|
# A room old enough to predate a setting has no line to correct.
|
|
for key, value in (
|
|
("MQTT_ENABLED", "true"),
|
|
("MQTT_HOST", BROKER_HOST),
|
|
("MQTT_PORT", BROKER_PORT),
|
|
):
|
|
if key not in seen:
|
|
changed.append(f"added {key}")
|
|
out.append(f"{key}={value}\n")
|
|
|
|
if changed:
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
handle.writelines(out)
|
|
|
|
for note in changed:
|
|
print(f"[classroom-migrate] device.conf: {note}")
|
|
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
|
|
# in XDG_CONFIG_DIRS, and the environment.d file that used to put it there
|
|
# never reached a session started by SDDM. The session script that does is
|
|
# installed by the package; this only checks it arrived, because a room
|
|
# without it is a room with no lockdown and no panel of ours.
|
|
if [[ ! -r /etc/xdg/plasma-workspace/env/zz-ekklesia-classroom.sh ]]; then
|
|
say "WARNING: the session environment script is missing;"
|
|
say " the desktop will come up as stock Plasma"
|
|
fi
|
|
|
|
say "done"
|