loop recovery: wait on the watchdog's ActiveState, and only a root flysim that is the running build

review-ladder r2: a oneshot probe mid-run is 'activating', which
is-active does not count, so the wait never waited. The root copy must
also be byte-identical to /opt/fly/current/flysim, so a manual rollback
cannot approve an archive the running build refuses. Docs: the hold
after an unrestorable rung, the post-deploy --list check, and never
stopping the unit mid-step.
This commit is contained in:
acamilo 2026-09-28 21:37:40 +00:00
parent f205d95e5e
commit 26892d8530
3 changed files with 55 additions and 11 deletions

View file

@ -94,12 +94,21 @@ migration_accepted() {
return 1
}
# The root copy must be the flysim that runs: a manual rollback of /opt/fly/current without a
# deploy would otherwise approve archives the running build refuses.
LIVE_FLYSIM=/opt/fly/current/flysim
[ "$(id -u)" -eq 0 ] || LIVE_FLYSIM="${FLY_LOOP_RESET_TEST_LIVE_FLYSIM:-$FLYSIM}"
same_build() {
[ -f "$LIVE_FLYSIM" ] \
&& [ "$(sha256sum < "$FLYSIM" | cut -d' ' -f1)" = "$(sha256sum < "$LIVE_FLYSIM" | cut -d' ' -f1)" ]
}
build_compat=""
if [ -x "$FLYSIM" ] && [ "$(stat -c %u "$FLYSIM")" = "$(id -u)" ]; then
if [ -x "$FLYSIM" ] && [ "$(stat -c %u "$FLYSIM")" = "$(id -u)" ] && same_build; then
build_compat="$(clean_env timeout 90 "$FLYSIM" --print-compatibility 2>/dev/null | tail -n1 || true)"
fi
if [ -z "$build_compat" ]; then
log "no compatibility string from $FLYSIM (missing, not owned by $(id -un), or failed); no rung is restorable"
log "no compatibility string from $FLYSIM (missing, not owned by $(id -un), not the running build, or failed); no rung is restorable"
[ "$LIST" -eq 1 ] && exit 0
exit 3
fi
@ -141,11 +150,18 @@ trap 'exit 143' TERM INT HUP
log "pausing ${WATCHDOG_TIMER} and stopping ${SERVICE} to reset to rung ${RANK}"
systemctl stop "$WATCHDOG_TIMER"
# A oneshot probe mid-run is "activating", which `is-active` does not count; wait on ActiveState.
watchdog_idle() {
case "$(systemctl show -p ActiveState --value "$WATCHDOG_SERVICE")" in
inactive|failed) return 0 ;;
*) return 1 ;;
esac
}
for _ in $(seq 1 60); do
systemctl is-active --quiet "$WATCHDOG_SERVICE" || break
watchdog_idle && break
sleep 1
done
if systemctl is-active --quiet "$WATCHDOG_SERVICE"; then
if ! watchdog_idle; then
log "${WATCHDOG_SERVICE} still running after 60 s; not resetting"
exit 4
fi

View file

@ -15,7 +15,8 @@ a ladder one step per trap that outlives the previous step:
- **Outlives** means the reports that confirm it again are at least 20 minutes after the step, so
their 10-brain-minute window lies after it. The helper waits that long after every step.
- **Budget:** at most two milestone resets per 24 hours. With the budget spent the step is a
restart, at most every three hours, until a reset is free again.
restart, at most every three hours, until a reset is free again. The same three-hour spacing applies
when a reset level finds no restorable rung and restarts instead.
- **Starting over:** the ladder returns to level 0 when the fly reaches a new best rung, or after
six hours with no suspected report.
@ -29,9 +30,12 @@ release env file so a deploy does not drop it.
The step runs `/opt/fly/bin/fly-loop-reset <rung>` through sudo (the one line in
`config/fly-sudoers`). As root it:
- runs only the root-owned `/opt/fly/sbin/flysim` that `05-deploy.sh` installs from the release
tarball after checking it against the tarball's MANIFEST, never the fly-owned release tree;
without that copy no rung is restorable;
- runs only the root-owned `/opt/fly/sbin/flysim`, and only while it is byte-identical to the
flysim `/opt/fly/current` points to, that `05-deploy.sh` installs from the release
tarball after checking it against the tarball's MANIFEST (never the fly-owned release tree).
Without that copy (an infra-only deploy, or a manual rollback) no rung is restorable and the
ladder only restarts; after a release deploy, check as `fly` that
`sudo -n /opt/fly/bin/fly-loop-reset --list` names the current rung;
- reads `fly.env` as `KEY=VALUE` data, never sources it, and ignores the caller's environment;
- stops `fly-watchdog.timer` and waits for a running probe to finish, so the watchdog cannot
start flysim on a half-rewritten store; stops flysim; runs `fly-reset-to-milestone`; and on every
@ -41,7 +45,9 @@ The reset copies both stores to `/srv/fly/state.reset-<UTC>` first, as in the ru
clears milestone archives above the rung. After each step the helper waits up to four minutes for
`/status` to report `running` (and the target rank for a reset). Otherwise the step is recorded
as failed and the ladder still climbs. The step is recorded before it runs, so a helper killed
mid-step has still climbed and spent the reset.
mid-step has still climbed and spent the reset. Do not stop `fly-loop-recover.service` during a
step: that kills the reset too, and the wrapper's exit trap starts flysim on whatever state is
left.
## On stream

View file

@ -267,7 +267,12 @@ class WrapperTests(unittest.TestCase):
self.calls = root / "calls.log"
(root / "state").mkdir()
(root / "bin").mkdir()
self.script(root / "bin/systemctl", f'echo "systemctl $*" >> {self.calls}; [ "$1" != is-active ] || exit 3')
# The watchdog probe is mid-run ("activating") for the first two looks.
self.script(root / "bin/systemctl", f'''echo "systemctl $*" >> {self.calls}
if [ "$1" = show ]; then
n=$(grep -c "^systemctl show" {self.calls})
if [ "$n" -le 2 ]; then echo activating; else echo inactive; fi
fi''')
self.script(root / "flysim", f'[ "$1" = --print-compatibility ] && echo "{BUILD}"')
self.script(root / "reset", f'echo "reset $* FLY_BIN=$FLY_BIN" >> {self.calls}')
self.env_file = root / "fly.env"
@ -288,6 +293,7 @@ class WrapperTests(unittest.TestCase):
env = {"PATH": f"{self.root}/bin:/usr/bin:/bin", "FLY_LOOP_RESET_TEST_STATE_DIR": str(self.root / "state"),
"FLY_LOOP_RESET_TEST_ENV_FILE": str(self.env_file), "FLY_LOOP_RESET_TEST_FLYSIM": str(self.root / "flysim"),
"FLY_LOOP_RESET_TEST_RESET_BIN": str(self.root / "reset")}
env.update(getattr(self, "extra_env", {}))
return recover.subprocess.run([str(WRAPPER), *args], env=env, capture_output=True, text=True, timeout=60)
def log(self):
@ -303,7 +309,9 @@ class WrapperTests(unittest.TestCase):
done = self.run_wrapper("12")
self.assertEqual(done.returncode, 0, done.stderr)
self.assertEqual([line.split()[:3] for line in self.log().splitlines() if not line.startswith("systemctl is-active")], [
["systemctl", "stop", "fly-watchdog.timer"], ["systemctl", "stop", "flysim.service"],
["systemctl", "stop", "fly-watchdog.timer"],
["systemctl", "show", "-p"], ["systemctl", "show", "-p"], ["systemctl", "show", "-p"], ["systemctl", "show", "-p"],
["systemctl", "stop", "flysim.service"],
["reset", "12", f"FLY_BIN={self.root}/flysim"],
["systemctl", "start", "flysim.service"], ["systemctl", "start", "fly-watchdog.timer"]])
@ -324,6 +332,20 @@ class WrapperTests(unittest.TestCase):
self.assertEqual(self.run_wrapper("12").returncode, 3)
self.assertEqual(self.log(), "")
def test_a_root_copy_that_is_not_the_running_build_restores_nothing(self):
other = self.root / "live-flysim"
self.script(other, "echo other build")
self.extra_env = {"FLY_LOOP_RESET_TEST_LIVE_FLYSIM": str(other)}
self.assertEqual(self.run_wrapper("--list").stdout, "")
self.assertEqual(self.run_wrapper("12").returncode, 3)
def test_a_watchdog_probe_that_never_finishes_blocks_the_reset(self):
self.script(self.root / "bin/systemctl", f'echo "systemctl $*" >> {self.calls}; [ "$1" != show ] || echo activating')
self.script(self.root / "bin/sleep", "exit 0")
self.assertEqual(self.run_wrapper("12").returncode, 4)
self.assertNotIn("systemctl stop flysim.service", self.log())
self.assertIn("systemctl start fly-watchdog.timer", self.log())
def test_bad_arguments_are_refused(self):
for args in ((), ("--check", "12"), ("12", "13"), ("../12",), ("123",), ("-1",)):
self.assertEqual(self.run_wrapper(*args).returncode, 2, args)