Host power-off for the household (R-998): POST /host/poweroff
gates / gates (push) Successful in 34s
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user