generated from OpsDeck/module_template
feat: restart the host, now or on a delay #3
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/restart-host"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The brief said not to add a reboot action unless asked for; it has now been asked for. The page could already say a reboot was needed and was the one place that could not do anything about it.
README.md's "There is no reboot action, by design" goes with it.Rebased onto
mainafter the task-runs rework landed, so the gates below speak the current model (isBusy("apt"), not the oldrunningboolean).What it looks like
A form rather than an action, because the delay is the decision:
shutdown -r now|+N "OpsDeck: restart requested by <user>"shutdown -c"Now" restarts on one confirmation; anything else schedules a restart that the cancel action calls off until it fires. The wall message names the admin who asked, and both routes are
role: "admin"and confirmed.The Host block gains Restart (the capability, or why not) and Scheduled restart (relative time).
Not a task run
shutdownreturns as soon as systemd has accepted the request, so there is no work to follow and nothing for Activity to hold — unlike the apt actions, these two answer directly. What is worth watching is the host, and that is on the page:<hostRoot>/run/systemd/shutdown/scheduled, which systemd writes when it accepts a shutdown and removes onshutdown -c. So a restart somebody scheduled over SSH shows up too, and asking costs no host command.parseScheduledShutdownis a pure parser inapt.tswith tests, like everything else there.Two gates
shutdownasks systemd over the bus and systemd authenticates bus peers by pid, so a mount strategy cannot substitute.host.tsgained the probe (host/proc/self/statvsDeno.pid— the read that does not need Deno's--allow-all); without it the routes answer 503 namingpid: host, and the page says the same instead of failing at the click. The compose example already sets it.isBusy("apt"). Restart is the one action still refused while the lock is busy: it does not queue behind it the way a second upgrade does, and a dist-upgrade cut in half leaves dpkg wanting--configure -aon a host that is now also rebooting.isBusyis "not terminal", so a queued run is refused too — that is exactly the run a reboot would strand before it started. Cancelling is never refused; mid-run is when it is wanted.Worth a reviewer's eye: this is the one place that deliberately does not follow "gate on host access alone".
canRestartgoes dark while a run is live, because a button that reboots the host is the wrong place to learn about a 409.Known rough edge
An immediate restart takes OpsDeck down with the host, so that response races the machine going away — the page usually shows the connection dropping rather than a result. That is the reason the delayed path exists, and it is in the README.
Verified
deno check,deno lint,deno fmt --check, 58 tests pass (56 existing + 2 new parser tests;alerts_test.ts's state fixture gainedshutdown: null). Ran against a fixture host tree: schema loads valid, the form and cancel action render,shutdownAtMsparses, the gates answer 503 with their reason. The actualshutdowncall is untested — it needs a Debian host withpid: host.🤖 Generated with Claude Code
11d67e8d58to9de3df4208Reviewed at
9de3df4. The design (form + cancel, pid-namespace probe,isBusygate on restart) holds up; two things need fixing before merge, the rest are small.Blocking
backend/mod.ts—starter()//upgrade/*: an upgrade can still be started while a restart is scheduled.The
isBusy("apt")gate closes one direction (no restart while a run is live), but the other direction is open: schedule a restart in 30 minutes, then click "Install all updates". The run starts, the reboot fires mid dist-upgrade, and the host is in exactly thedpkg --configure -astate the PR text says the gate exists to prevent. Refuse/upgrade/securityand/upgrade/allwith a 409 whiles.shutdown !== null(a middleware next topidNamespaceRequiredis enough), and dropcanUpgradeSecurity/canUpgradeAllin/summaryfor the same condition so the buttons go dark rather than 409./refreshcan stay open — an interruptedapt-get updateis harmless.backend/mod.ts—/restartbody parsing coerces bad input to an immediate reboot.Math.floor(Number(body.delayMin ?? 0))turns"",null,false, and[]into0, which isshutdown -r now. A client that sends an empty or malformed body (or a select that submits"") reboots the host right away instead of getting a 400. RequiredelayMinto be present and either a non-negative integer or a/^\d+$/string; anything else is a 400. The form's default of"0"still gets "Now" on the happy path.Non-blocking
backend/mod.ts/summary:restartTextsaysavailablewhile a run is busy, butcanRestartisfalseso the form's submit is hidden. Add a branch for that case (blocked — update run in progress) so the Host block matches the controls.backend/host.tsprobePidNamespace: a failed read (hostProc not mounted, Deno permission) becomesfalse, and the UI then tells the operator to addpid: host— wrong advice when the compose file already has it. Keep the failure reason and surface it separately from "own PID namespace".backend/mod.ts/restart: theisBusycheck and theshutdowncall are not atomic; a run started in between proceeds. Small window, fine to accept, but worth a comment.backend/mod.ts/restart:systemctlthat believes it is in a chroot printsRunning in chroot, ignoring request.and exits 0. Only reachable if the pid-namespace probe false-positives, but the response would then say "restarting now" with nothing happening. Cheap defence: treat stderr containingignoring requestas a failure.backend/mod.tsUI schema: "Cancel scheduled restart" andcanCancelRestartare also true for aMODE=poweroff/haltscheduled over SSH. Either uses.shutdown.modein the label or gate onmode === "reboot".Checked and fine: core's
relativeMsrenders future times ("in 5m") andnullas "—"; the compose file setspid: hostandOPSDECK_HOST_PROC=/host/root/proc, so the/host/root/proc/self/statprobe is legitimate; the wall message goes through argv (chroot/nsenter), not a shell, so the user name is safe.Thanks, all seven points checked against the code. Five fixed as you suggested. The isBusy/shutdown race is closed rather than commented, and the cancel gate is fixed a different way; reasons for both are below. Everything is in
9826480.Blocking
Upgrade while a restart is scheduled. Confirmed, fixed.
/upgrade/securityand/upgrade/allnow go through anoShutdownScheduledmiddleware that returns 409 while/run/systemd/shutdown/scheduledexists. It checks for any mode, not onlyreboot, because a poweroff scheduled over SSH cuts a dist-upgrade off just as hard. It reads the file fresh on each request, not from the 10 sload()cache, since the cache could miss a restart scheduled a second earlier.canUpgradeSecurity/canUpgradeAllalso go false while one is scheduled./refreshstays open.delayMincoercion. Confirmed:"",null,false,[], a missing field and an unparsable body (.catch(() => ({}))) all ended up asshutdown -r now. It is nowparseRestartDelayin a new purerestart.ts. It accepts a non-negative integer or a/^\d+$/string up to the max, and returns 400 for anything else.restart_test.tscovers those cases plustrue,"1.5"," 5","0x10","1e2"and over-max.Non-blocking
restartTextwhile busy: fixed, it now saysblocked — update run in progress.probePidNamespaceread failure: fixed. The host now exposespidNamespaceError. When it is set,restartTextand the 503 show the read error and point atOPSDECK_HOST_PROCinstead of recommendingpid: host. The log level also went from debug to warn./restartsets arestartingflag and the upgrade gate raises anupgradesStartingcounter. Each one checks the other side's value synchronously, with no await between the check and its own set, so the single-threaded event loop makes them exclude each other./restartalso parses the body before the busy check now, so that check and setting the flag happen with no await between them. This does not cover a restart scheduled over SSH at the same moment someone clicks upgrade, and nothing inside the process can.ignoring requestexit 0: fixed for both/restartand/restart/cancel:shutdownErrornow treats exit 0 withignoring requestin its output as a failure.mode === "reboot".shutdown -ccancels any mode, and because of the first blocking fix, a poweroff scheduled over SSH now also blocks the upgrades. If we hid the cancel for that case, the page would show "poweroff scheduled", upgrade buttons that are greyed out, and no way to clear it except a shell. So the action is now labelled "Cancel scheduled shutdown", its confirmation names all three modes, and the field reads "Scheduled shutdown".Checks:
deno lintanddeno checkare clean, anddeno testpasses (61 passed, 0 failed), all run inside a core checkout. I did not reboot a real host with it, because neither restart route can run against a fixture tree.deno fmt --line-width 100reports one hunk inmod.ts(L398) that was already there before this PR; I left it alone.Re-reviewed at
9826480. Every point from the previous review is fixed.noShutdownScheduledreads the scheduled file fresh on every request and accepts any mode./upgrade/*returns 409 andcanUpgrade*goes false.parseRestartDelayreturns 400 for malformed input and never falls back to an immediate reboot.restart_test.tscovers the coercion cases.restartingflag and theupgradesStartingcounter exclude each other./restartparses the body first, then checksisBusy || upgradesStarting || restartingand sets the flag with no await in between. The upgrade middleware checks the flag after its only await and increments beforenext(). Both reset infinally.pidNamespaceErroris surfaced separately from thepid: hostadvice.shutdownIgnored()catches the chroot exit-0 case. The cancel is relabelled "Cancel scheduled shutdown", and your reason for not gating it onmode === "reboot"makes sense.One non-blocking nit: if
shutdownhitsREBOOT_TIMEOUT_MS, the flag clears and the response reports failure, but systemd may already have accepted the request. For a delayed restart that is harmless, because the scheduled file then blocks upgrades anyway. It may be worth wording that error as "outcome unknown" rather than "failed".The actual reboot path is still untested on a real host, as the PR says. Smoke-test it on prod after deploy.