test: the lab measures the update schedule on its own systemd, powered off and on

Switched on at the time: the timer fires once and is next due tomorrow.
Switched off: the lab machine is really stopped across the time and
started after it; the missed check runs once, 50 s after starting, and
never loops. And the reboot-when-idle watcher against real kernel input,
a mouse made with /dev/uinput: no reboot while it moves, a reboot once it
is left alone. 11 checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-09-26 22:56:08 -05:00
co-authored by Claude Opus 5.5
parent 3c40e849ea
commit 7586326902
+162
View File
@@ -0,0 +1,162 @@
#!/bin/bash
# When a classroom checks for updates, measured on the lab's own systemd.
#
# switched on at the time the timer fires once at its time, and not
# again that day
# switched off at the time the lab machine is really stopped across the
# time, then started: the missed check runs
# once after it boots, and does not loop
#
# A pair of test units stands in for ekklesia-classroom-update: the same
# [Timer] settings with the time moved to a minute or two from now (the
# real one would update the lab for real). The real timer's own settings,
# after installing the package, are the release gate's to check.
#
# And classroom-reboot-when-idle against real kernel input: a mouse made
# with /dev/uinput, moved and then left alone, with a harmless command in
# place of the reboot.
#
# tools/lab/update-schedule-check
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
NAME="${LAB_NAME:-classroom-v2-lab}"
LAB="$ROOT/tools/lab/lab"
IDLE="${IDLE_COMMAND:-/usr/local/bin/classroom-reboot-when-idle}"
LOG=/var/tmp/ekk-schedule-test.log
lab() { docker exec -i "$NAME" bash -c "$1"; }
passed=0
failed=0
judge() {
if eval "$2"; then
printf ' \033[32mPASS\033[0m %s\n' "$1"
passed=$((passed + 1))
else
printf ' \033[31mFAIL\033[0m %s\n' "$1"
failed=$((failed + 1))
fi
}
# A timer like the real one, due at HH:MM:SS local time, with DELAY spread.
install_test_timer() { # WHEN DELAY
lab "cat > /etc/systemd/system/ekk-schedule-test.service <<'UNIT'
[Unit]
Description=Stand-in for the update check
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo \"fired \$(date +%%s) boot \$(cut -d. -f1 /proc/uptime)\" >> $LOG'
UNIT
cat > /etc/systemd/system/ekk-schedule-test.timer <<UNIT
[Unit]
Description=Stand-in for the update check
[Timer]
OnCalendar=*-*-* $1
RandomizedDelaySec=$2
Persistent=true
[Install]
WantedBy=timers.target
UNIT
rm -f $LOG /var/lib/systemd/timers/stamp-ekk-schedule-test.timer
systemctl daemon-reload
systemctl enable --now ekk-schedule-test.timer >/dev/null 2>&1"
}
fired() { lab "grep -c fired $LOG 2>/dev/null || echo 0" | tail -1; }
local_in() { lab "date -d '+$1 seconds' +%H:%M:%S"; } # local time SECONDS from now
echo "switched on at the time"
due=$(local_in 60)
install_test_timer "$due" 0
judge "the stand-in is waiting for $due, local time" \
"lab 'systemctl list-timers ekk-schedule-test.timer --no-legend | grep -q ekk-schedule-test'"
sleep 80
judge "at $due it checked, once" '[[ $(fired) -eq 1 ]]'
sleep 60
judge "and not again (a minute later, still once)" '[[ $(fired) -eq 1 ]]'
next=$(lab "systemctl show ekk-schedule-test.timer -p NextElapseUSecRealtime --value")
tomorrow=$(lab "date -d tomorrow +%Y-%m-%d")
judge "its next check is tomorrow at $due ($next)" '[[ "$next" == *"$tomorrow $due"* ]]'
echo "switched off at the time"
due=$(local_in 90)
install_test_timer "$due" 60
lab "systemctl start ekk-schedule-test.timer; sync"
# A stamp from a previous check, as every classroom has one: last checked
# yesterday. The catch-up is decided against it.
lab "touch -d yesterday /var/lib/systemd/timers/stamp-ekk-schedule-test.timer"
echo " powering the lab off before $due"
docker stop -t 20 "$NAME" >/dev/null
sleep 150
echo " and on again, after it"
"$LAB" reboot >/dev/null 2>&1
# When the lab machine really started (inside a container /proc/uptime is
# the host's, so this is asked of docker).
booted=$(date -d "$(docker inspect -f '{{.State.StartedAt}}' "$NAME")" +%s)
judge "while it was off, nothing ran" '[[ $(lab "grep -c fired $LOG 2>/dev/null || echo 0" | tail -1) -le 1 ]]'
for _ in $(seq 1 24); do [[ $(fired) -ge 1 ]] && break; sleep 5; done
judge "switched on after $due: the missed check ran after it booted ($(lab "tail -1 $LOG"))" '[[ $(fired) -eq 1 ]]'
after=$(( $(lab "awk '{print \$2}' $LOG | head -1") - booted ))
judge "$after s after it started: once it was up, within the minute of spread" '(( after >= 0 && after <= 150 ))'
sleep 90
judge "and only once: no loop after booting (90 s later, still once)" '[[ $(fired) -eq 1 ]]'
lab "systemctl disable --now ekk-schedule-test.timer >/dev/null 2>&1; rm -f /etc/systemd/system/ekk-schedule-test.* $LOG /var/lib/systemd/timers/stamp-ekk-schedule-test.timer; systemctl daemon-reload"
echo "reboot only when nobody is using it"
docker cp "$ROOT/scripts/classroom-reboot-when-idle" "$NAME:/opt/classroom-reboot-when-idle" >/dev/null
lab "chmod 755 /opt/classroom-reboot-when-idle; rm -f /tmp/rebooted"
[[ "$IDLE" == /usr/local/bin/* ]] && lab "test -x $IDLE" || IDLE=/opt/classroom-reboot-when-idle
cat > /tmp/update-schedule-mouse.py <<'PY'
"""A mouse made with /dev/uinput: real kernel input events. Moves for N s, then stays."""
import fcntl, os, struct, sys, time
UI_SET_EVBIT, UI_SET_RELBIT, UI_SET_KEYBIT = 0x40045564, 0x40045566, 0x40045565
UI_DEV_CREATE, UI_DEV_DESTROY = 0x5501, 0x5502
EV_SYN, EV_KEY, EV_REL, REL_X, BTN_LEFT = 0, 1, 2, 0, 0x110
fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
for ioctl, value in ((UI_SET_EVBIT, EV_KEY), (UI_SET_KEYBIT, BTN_LEFT), (UI_SET_EVBIT, EV_REL), (UI_SET_RELBIT, REL_X)):
fcntl.ioctl(fd, ioctl, value)
os.write(fd, struct.pack("80sHHHHi", b"lab-classroom-mouse", 3, 1, 1, 1, 0) + b"\0" * (4 * 64 * 4))
fcntl.ioctl(fd, UI_DEV_CREATE)
time.sleep(1)
def event(kind, code, value):
now = time.time()
os.write(fd, struct.pack("llHHi", int(now), int(now % 1 * 1e6), kind, code, value))
end = time.monotonic() + float(sys.argv[1])
while time.monotonic() < end:
event(EV_REL, REL_X, 1); event(EV_SYN, 0, 0)
time.sleep(2)
print("mouse left alone", flush=True)
time.sleep(float(sys.argv[2]))
fcntl.ioctl(fd, UI_DEV_DESTROY)
PY
docker cp /tmp/update-schedule-mouse.py "$NAME:/opt/update-schedule-mouse.py" >/dev/null
rm -f /tmp/update-schedule-mouse.py
docker exec -d "$NAME" python3 /opt/update-schedule-mouse.py 40 90
sleep 2
# The kernel makes the device; on a classroom machine devtmpfs gives it a
# node in /dev/input. The lab's /dev is a plain tmpfs, so the node is made
# here, from what sysfs says, as devtmpfs would.
lab 'for e in /sys/class/input/event*; do
[[ "$(cat $e/device/name)" == lab-classroom-mouse ]] || continue
IFS=: read -r major minor < $e/dev
mknod /dev/input/$(basename $e) c $major $minor 2>/dev/null
echo $(basename $e) > /tmp/lab-mouse-node
done'
sleep 1
docker exec -d "$NAME" sh -c "$IDLE --idle-seconds 25 --check-seconds 2 --reboot-command touch /tmp/rebooted > /tmp/idle.log 2>&1"
sleep 35
judge "the mouse moved every 2 s for 40 s: no reboot, against a 25 s limit" "! lab 'test -e /tmp/rebooted'"
judge "it was watching real devices ($(lab 'grep -o "watching [0-9]* input devices" /tmp/idle.log'))" \
"lab 'grep -qE \"watching [1-9][0-9]* input devices\" /tmp/idle.log'"
sleep 40
judge "left alone for longer than the limit: it rebooted" "lab 'test -e /tmp/rebooted'"
lab "pkill -f '[u]pdate-schedule-mouse'; rm -f /dev/input/\$(cat /tmp/lab-mouse-node 2>/dev/null) /tmp/lab-mouse-node /tmp/rebooted /opt/update-schedule-mouse.py /opt/classroom-reboot-when-idle"
echo
echo "$passed passed, $failed failed"
[[ "$failed" -eq 0 ]]