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
+11
View File
@@ -123,6 +123,7 @@ type Collector struct {
ctrlSup ControllerSupervisorReporter // R-523: in-guest controller supervisor (nil → stanza omitted)
guestNet GuestNetReporter // R-54: per-guest network watchdog (nil → stanza omitted)
diskTrim GuestDiskTrimReporter // R-444: weekly guest disk trim (nil → stanza omitted)
deliberateOff func() *DeliberateOff // R-998: household power-off record of this boot (nil → omitted)
foreignKey ForeignKeyArchiveReporter // R-366 slice 2 (nil → omitted)
selfUpdate SelfUpdateReporter // D1: agent self-update pending status (nil → false)
mgmtPlane MgmtPlaneReporter // G1: management-plane health (nil → stanza omitted)
@@ -255,6 +256,12 @@ func (c *Collector) SetGuestDiskTrimReporter(r GuestDiskTrimReporter) *Collector
return c
}
// SetDeliberateOffSource wires the R-998 power-off record (poweroff.State.Current, adapted).
func (c *Collector) SetDeliberateOffSource(f func() *DeliberateOff) *Collector {
c.deliberateOff = f
return c
}
// SelfUpdateReporter is the D1 seam the selfupdate commit-manager plugs into (same consumer-side
// pattern — hub does not import selfupdate). nil (feature not wired) → pending=false on the report.
type SelfUpdateReporter interface {
@@ -431,6 +438,10 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) {
if c.oob != nil {
report.OOB = c.oob.OOBStatus(ctx)
}
// R-998: a household power-off accepted in this boot rides every report until the box is down.
if c.deliberateOff != nil {
report.DeliberateOff = c.deliberateOff()
}
return report, nil
}
+29
View File
@@ -0,0 +1,29 @@
package hub
import (
"context"
"errors"
"testing"
"time"
)
// R-998: the power-off path waits on SetReportedHook, so the hook must fire only for a report the hub
// ACCEPTED — a failed push must not let the box power off believing the hub knows.
func TestLoop_ReportedHookOnlyAfterAcceptedReport(t *testing.T) {
var cn, rn int32
rep := &HostReport{DeliberateOff: &DeliberateOff{RequestedAt: "2026-10-13T18:00:00Z"}}
loop := NewLoop(&fakeCollector{report: rep, n: &cn},
&fakeReporter{env: &ControlEnvelope{}, errSeq: []error{errors.New("hub 5xx")}, n: &rn},
900*time.Second, quietLogger())
var seen []*HostReport
loop.SetReportedHook(func(r *HostReport) { seen = append(seen, r) })
loop.cycle(context.Background(), 900*time.Second) // the push fails
if len(seen) != 0 {
t.Fatalf("hook fired for a refused report: %d", len(seen))
}
loop.cycle(context.Background(), 900*time.Second) // the push is accepted
if len(seen) != 1 || seen[0].DeliberateOff == nil || seen[0].DeliberateOff.RequestedAt != "2026-10-13T18:00:00Z" {
t.Fatalf("hook after an accepted report = %+v", seen)
}
}
+10
View File
@@ -66,8 +66,15 @@ type Loop struct {
// retry, no duplicate shipping). Loop state is single-goroutine (cycle only).
logTailSource func(maxBytes int) []string
logTailPending bool
// reported (R-998) is called after every report the hub ACCEPTED, with that report. The power-off
// path waits on it so the hub has the deliberate-off record before the box goes down.
reported func(r *HostReport)
}
// SetReportedHook wires the R-998 after-report observer. Optional.
func (l *Loop) SetReportedHook(f func(r *HostReport)) { l.reported = f }
// logTailMaxBytes caps the heartbeat log tail (newest lines kept).
const logTailMaxBytes = 128 * 1024
@@ -156,6 +163,9 @@ func (l *Loop) cycle(ctx context.Context, current time.Duration) time.Duration {
l.logger.Warn("hub: report failed; keeping current interval", "err", err)
return current
}
if l.reported != nil {
l.reported(report)
}
if report.LogTail != nil {
// Transparency: the pull is visible in the box's own log (and thus in the ring).
l.logger.Info("operator log pull served", "component", "agent", "lines", len(report.LogTail.Lines))
+11
View File
@@ -144,6 +144,12 @@ type HostReport struct {
// in the steady state, so the cross-repo host-report golden stays byte-stable.
LogTail *LogTail `json:"log_tail,omitempty"`
// DeliberateOff (R-998, doc 14): the household asked this box to power off, in THIS boot. Present on
// every report from the request until the box goes down; absent again after the next boot — that
// absence is how the hub learns the box is back. The hub holds its silence alarms while it is set
// (14 days). `omitempty`: absent in the steady state, so the cross-repo host-report golden stays stable.
DeliberateOff *DeliberateOff `json:"deliberate_off,omitempty"`
// OOB is the operator-access health stanza (TASK H1). It answers the operator's question — "can I
// get into this box right now, and if not, why" — from the hub: felhom-sshd up + on which port,
// locally reachable, the tunnel handshake age (the OOB path rides wg-felhom), whether the operator
@@ -758,3 +764,8 @@ type ForeignKeyArchives struct {
Oldest string `json:"oldest"`
Newest string `json:"newest"`
}
// DeliberateOff is the household-requested power-off record (R-998).
type DeliberateOff struct {
RequestedAt string `json:"requested_at"` // RFC3339 UTC, the agent's clock
}