Host power-off for the household (R-998): POST /host/poweroff
gates / gates (push) Successful in 34s

The guest's controller asks; the agent refuses in the first 10 min after boot, a second time in
one boot (persisted, keyed by /proc/stat btime), and while a backup, restore test, OS step,
controller update, drive format, NAS verify, escrow ceremony or vzdump runs (each a named 409
reason). On acceptance: the record first, a host report carrying deliberate_off accepted by the
hub (bounded 60 s), then pct shutdown --timeout 120, then systemctl poweroff.

Sudoers: two exact vectors (pct ^shutdown N --timeout N$, systemctl poweroff) in FELHOM_POWEROFF;
nine injection cases. New TestEveryAliasIsGranted: a defined alias missing from the grant line
passed the coverage check before. Every new test red-proofed.
This commit is contained in:
2026-10-13 19:29:52 +02:00
parent 86adbcaf8a
commit 3403c87c80
15 changed files with 792 additions and 2 deletions
+47
View File
@@ -50,6 +50,7 @@ import (
"gitea.dooplex.hu/admin/felhom-agent/internal/pbs"
"gitea.dooplex.hu/admin/felhom-agent/internal/pbsdr"
"gitea.dooplex.hu/admin/felhom-agent/internal/poke"
"gitea.dooplex.hu/admin/felhom-agent/internal/poweroff"
"gitea.dooplex.hu/admin/felhom-agent/internal/provision"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
@@ -833,7 +834,25 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
// (The startup self-check log runs AFTER the pbsdr manager is wired below, so its snapshot
// already carries the gated view — v0.86.0.)
collector.SetCapabilityProber(probeAll)
// R-998 (doc 14): the household power-off record of THIS boot rides every report until the box is down.
powerOffState := poweroff.New(filepath.Join(cfg.OOB.WithDefaults().StateDir, "poweroff.json"), poweroff.ProcBtime)
collector.SetDeliberateOffSource(func() *hub.DeliberateOff {
if r := powerOffState.Current(); r != nil {
return &hub.DeliberateOff{RequestedAt: r.RequestedAt.UTC().Format(time.RFC3339)}
}
return nil
})
loop := hub.NewLoop(collector, client, time.Duration(hcfg.PollSeconds)*time.Second, logger)
// R-998: the power-off path waits until the hub ACCEPTED a report carrying the record.
powerOffAck := make(chan struct{}, 1)
loop.SetReportedHook(func(r *hub.HostReport) {
if r != nil && r.DeliberateOff != nil {
select {
case powerOffAck <- struct{}{}:
default:
}
}
})
interval := time.Duration(hcfg.PollSeconds) * time.Second
// Heartbeat log-pull (v0.83.0): when the control envelope requests it, the NEXT
// heartbeat carries the debug ring's tail (newest-kept, byte-capped in the loop).
@@ -1180,6 +1199,34 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
}
localSrv := buildLocalAPIServer(cfg, px, backupStore, heavyOps, observer, driveKnown, hostOps, gate, collector, client, intentRec, guestBindStore, formatJobStore, logRing, escrowCeremonyCfg, logger, &localTokens)
if localSrv != nil {
// R-998 (doc 14): POST /host/poweroff — guest shutdown, then host power-off; guards in hostpoweroff.go.
pwMode := proxmox.RunnerMode(cfg.Privileged.Mode)
if pwMode == "" {
pwMode = proxmox.RunnerSudo
}
localSrv.SetHostPowerOff(localapi.HostPowerOffConfig{
State: powerOffState,
Power: localapi.NewGuestBinder(&proxmox.ExecRunner{Mode: pwMode, SudoPath: cfg.Privileged.SudoPath}, logger),
ReportNow: func(ctx context.Context) bool {
select {
case storageTrigger <- struct{}{}:
default: // a report is already queued; it runs after the record was written
}
select {
case <-powerOffAck:
return true
case <-ctx.Done():
return false
}
},
Uptime: func() (time.Duration, bool) {
b, err := strconv.ParseInt(poweroff.ProcBtime(), 10, 64)
if err != nil || b <= 0 {
return 0, false
}
return time.Since(time.Unix(b, 0)), true
},
})
localSrv.SetAfterPrimaryBackup(func(ctx context.Context, vmid int) {
// Let the controller finish bringing its apps back after the backup, then run (still under the gate).
select {