feat: D1 Part 2 — agent self-update Go plumbing (op class, opsign, executor, commit, report)
- reconcile: ClassAgentUpdate op class; always Destructive (no provenance
blesses replacing the root-adjacent binary). classify test + companion
(TestClassify_AgentUpdateAlwaysDestructive).
- opsign: `-op agent_update` with -agent-version + -sha256 (isHex64-validated);
params {version,sha256}. isHex64 test (Group D).
- config: SelfUpdateConfig{URLTemplate,Username,Token,StateDir,DwellSeconds}
+ WithDefaults + Token redaction.
- internal/selfupdate: Executor (download → verify vs the SIGNED sha → sudo -n
wrapper `apply`; sha is the only integrity root — mismatch refuses + removes,
agent untouched); Manager (startup dwell → `commit`; version-mismatch → no
commit + loud WARN + marker left for report visibility; shutdown-before-dwell
leaves pending). WrapperRunner seam → tests never shell out.
- hub report: additive selfupdate_pending(+version) via SetSelfUpdateReporter
seam; both omitempty (Wireguard precedent) so the cross-repo golden contract
stays byte-stable — no hub change.
- capability manifest: 3 non-critical FELHOM_SELFUPDATE probes.
- main.go: updateExec appended to the executor chain; commit-manager wired to
the report seam + MaybeCommit goroutine after core init.
Tests: Group A (executor happy/sha-mismatch+companion/bad-params/wrapper-fail),
B (agent_update rides the real gate: pinned-key executes, non-pinned +
retarget rejected), C (commit/version-mismatch/no-pending/shutdown), D (opsign).
C2 companion red-proof verified (neutered Go verify → bad binary reaches apply
→ test fails), reverted. Full go test ./... green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -68,6 +68,7 @@ type Collector struct {
|
||||
capProbe func(ctx context.Context) []capability.Status // v0.44.0: privileged-capability self-check (nil → empty)
|
||||
leafFP string // v0.48.0: served local-API leaf fp (static per process; "" when local API disabled)
|
||||
wg WireguardReporter // S3: offsite-tunnel status (nil → stanza omitted)
|
||||
selfUpdate SelfUpdateReporter // D1: agent self-update pending status (nil → false)
|
||||
hostID string
|
||||
agentVersion string
|
||||
logger *slog.Logger
|
||||
@@ -124,6 +125,21 @@ func (c *Collector) SetWireguardReporter(w WireguardReporter) *Collector {
|
||||
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 {
|
||||
// SelfUpdatePending reports whether a signed update has flipped the binary but not yet
|
||||
// committed, and the awaited version.
|
||||
SelfUpdatePending() (pending bool, version string)
|
||||
}
|
||||
|
||||
// SetSelfUpdateReporter wires the agent self-update pending-status source (D1; nil-safe → false).
|
||||
// Returns the collector for chaining.
|
||||
func (c *Collector) SetSelfUpdateReporter(s SelfUpdateReporter) *Collector {
|
||||
c.selfUpdate = s
|
||||
return c
|
||||
}
|
||||
|
||||
// Collect builds the report. Best-effort liveness: a failed NodeStatus is a hard
|
||||
// error (no useful report — the cycle skips the POST); a failed per-guest
|
||||
// GuestConfig degrades that guest to status="unknown" without spec but still sends;
|
||||
@@ -162,6 +178,10 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) {
|
||||
if c.wg != nil {
|
||||
report.Wireguard = c.wg.WireguardStatus(ctx)
|
||||
}
|
||||
// D1: agent self-update pending status (nil reporter → pending=false, the steady state).
|
||||
if c.selfUpdate != nil {
|
||||
report.SelfUpdatePending, report.SelfUpdatePendingVersion = c.selfUpdate.SelfUpdatePending()
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,19 @@ type HostReport struct {
|
||||
// needed; the pubkey here is the operator's revocation-recovery handle (re-add the peer with
|
||||
// it). Carries NO secret — the pubkey is public by definition.
|
||||
Wireguard *WireguardStatus `json:"wireguard,omitempty"`
|
||||
|
||||
// SelfUpdatePending is true when an operator-signed agent self-update has flipped the binary
|
||||
// but the new binary has not yet committed (TASK D1). SelfUpdatePendingVersion names the
|
||||
// awaited version when pending. A runs-but-never-commits binary reports pending=true every
|
||||
// heartbeat → the operator sees WHY the version isn't advancing; a crash-loop is auto-rolled
|
||||
// back by systemd and this flips back to false when the good binary re-commits/clears. The
|
||||
// report is stored opaquely hub-side, so these additive fields need no hub-schema change.
|
||||
// Both are `omitempty` (the Wireguard precedent): in the steady state (no update in flight)
|
||||
// they are absent — which keeps the cross-repo host-report golden contract byte-stable without
|
||||
// a hub change. They appear only while an update is pending. The hub reads an absent field as
|
||||
// pending=false, the correct default.
|
||||
SelfUpdatePending bool `json:"selfupdate_pending,omitempty"`
|
||||
SelfUpdatePendingVersion string `json:"selfupdate_pending_version,omitempty"`
|
||||
}
|
||||
|
||||
// WireguardStatus is the per-heartbeat offsite-tunnel status (S3). LastHandshakeAgeS is nil when
|
||||
|
||||
Reference in New Issue
Block a user