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:
@@ -0,0 +1,122 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manager owns the post-restart COMMIT half of the A/B update (TASK D1 Scenario D). After a flip,
|
||||
// the NEW binary boots with a pending marker on disk; once it has run cleanly for the dwell AND
|
||||
// core init is done, it calls the wrapper's `commit` to clear the marker. A crash before that →
|
||||
// systemd + the wrapper roll back to .prev (this Manager never rolls back).
|
||||
type Manager struct {
|
||||
stateDir string
|
||||
runningVersion string
|
||||
dwell time.Duration
|
||||
runner WrapperRunner
|
||||
logger *slog.Logger
|
||||
now func() time.Time // injectable for tests
|
||||
sleep func(context.Context, time.Duration)
|
||||
}
|
||||
|
||||
// ManagerConfig wires the commit manager.
|
||||
type ManagerConfig struct {
|
||||
StateDir string
|
||||
RunningVersion string // this binary's own version (main.version)
|
||||
Dwell time.Duration // 0 → 60s
|
||||
Runner WrapperRunner
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewManager builds the commit manager.
|
||||
func NewManager(cfg ManagerConfig) *Manager {
|
||||
dwell := cfg.Dwell
|
||||
if dwell == 0 {
|
||||
dwell = 60 * time.Second
|
||||
}
|
||||
return &Manager{
|
||||
stateDir: cfg.StateDir,
|
||||
runningVersion: cfg.RunningVersion,
|
||||
dwell: dwell,
|
||||
runner: cfg.Runner,
|
||||
logger: orDefaultLogger(cfg.Logger),
|
||||
now: func() time.Time { return time.Now() },
|
||||
sleep: func(ctx context.Context, d time.Duration) {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-t.C:
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// PendingStatus is the report-facing snapshot: whether an update is awaiting commit, and (when so)
|
||||
// the target version. Read synchronously at report-build time — cheap (one stat+parse).
|
||||
type PendingStatus struct {
|
||||
Pending bool
|
||||
Version string
|
||||
}
|
||||
|
||||
// Status returns the current pending-marker state for the host report. A read error is treated as
|
||||
// "not pending" for the report (the loud path is the commit goroutine's WARNs), never fatal.
|
||||
func (m *Manager) Status() PendingStatus {
|
||||
pm, err := readPending(m.stateDir)
|
||||
if err != nil || pm == nil {
|
||||
return PendingStatus{}
|
||||
}
|
||||
return PendingStatus{Pending: true, Version: pm.NewVersion}
|
||||
}
|
||||
|
||||
// SelfUpdatePending satisfies hub.SelfUpdateReporter (the report seam): pending + awaited version.
|
||||
func (m *Manager) SelfUpdatePending() (bool, string) {
|
||||
s := m.Status()
|
||||
return s.Pending, s.Version
|
||||
}
|
||||
|
||||
// MaybeCommit runs the startup commit decision, blocking for the dwell when a matching update is
|
||||
// pending. Call it in a goroutine AFTER core init (config parsed, local API up, control loop
|
||||
// started) — never on the startup critical path. Behaviour (Scenario C5 + D):
|
||||
// - no pending marker → nothing to do (the common case).
|
||||
// - pending.new_version == this running version → dwell, then `commit` (clears the marker).
|
||||
// - pending.new_version != running version → do NOT commit; loud WARN; leave the marker so the
|
||||
// hub report shows pending=true (a human decides — the box is in a weird state).
|
||||
func (m *Manager) MaybeCommit(ctx context.Context) {
|
||||
pm, err := readPending(m.stateDir)
|
||||
if err != nil {
|
||||
m.logger.Error("selfupdate: cannot read pending marker — not committing", "err", err)
|
||||
return
|
||||
}
|
||||
if pm == nil {
|
||||
return // no update in flight
|
||||
}
|
||||
if pm.NewVersion != m.runningVersion {
|
||||
// The running binary is NOT the one this pending marker describes. Do not commit (committing
|
||||
// would bless a state we can't explain). Leave the marker → the report surfaces pending=true.
|
||||
m.logger.Warn("selfupdate: pending marker version does not match the running binary — NOT committing (human review)",
|
||||
"pending_new_version", pm.NewVersion, "running_version", m.runningVersion, "pending_old_version", pm.OldVersion)
|
||||
return
|
||||
}
|
||||
|
||||
m.logger.Warn("selfupdate: new version running — dwelling before commit",
|
||||
"version", m.runningVersion, "prev", pm.OldVersion, "dwell", m.dwell)
|
||||
m.sleep(ctx, m.dwell)
|
||||
if ctx.Err() != nil {
|
||||
// Shutting down before the dwell elapsed — leave pending; the next start re-dwells and
|
||||
// commits. A restart is not a crash (no OnFailure), so this does not trigger rollback.
|
||||
m.logger.Warn("selfupdate: shutdown before commit dwell elapsed — pending left for next start")
|
||||
return
|
||||
}
|
||||
if m.runner == nil {
|
||||
m.logger.Error("selfupdate: no wrapper runner — cannot commit (marker left)")
|
||||
return
|
||||
}
|
||||
stdout, stderr, err := m.runner.Run(ctx, wrapperPath, "commit")
|
||||
if err != nil {
|
||||
m.logger.Error("selfupdate: commit failed (marker left; will retry next start)", "err", err, "stderr", string(stderr))
|
||||
return
|
||||
}
|
||||
m.logger.Warn("selfupdate: update committed", "version", m.runningVersion, "wrapper", trim(stdout))
|
||||
}
|
||||
Reference in New Issue
Block a user