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:
@@ -40,6 +40,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/provision"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/selfupdate"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/wgtunnel"
|
||||
@@ -616,7 +617,36 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
|
||||
decommIntent = intentStore
|
||||
}
|
||||
decommExec := signedjobs.NewDecommissionExecutor(decommIntent, logger)
|
||||
jobsRunner := signedjobs.NewRunner(client, gate, signedjobs.ExecutorChain{wipeExec, decommExec}, cfg.Hub.HostID, logger)
|
||||
|
||||
// Agent self-update (TASK D1): the agent_update executor downloads the operator-signed binary,
|
||||
// verifies it against the SIGNED sha256, and hands it to the root guarded wrapper (A/B flip +
|
||||
// detached restart). Wired as a third chain element. The commit-manager (below) commits a good
|
||||
// update after a clean dwell; systemd + the wrapper auto-roll-back a crash-looping one. sudoRunner
|
||||
// shells the wrapper verbs via `sudo -n` (same mode the rest of the privileged surface uses).
|
||||
suCfg := cfg.SelfUpdate.WithDefaults()
|
||||
suMode := proxmox.RunnerMode(cfg.Privileged.Mode)
|
||||
if suMode == "" {
|
||||
suMode = proxmox.RunnerSudo
|
||||
}
|
||||
suRunner := &proxmox.ExecRunner{Mode: suMode, SudoPath: cfg.Privileged.SudoPath}
|
||||
updateExec := selfupdate.NewExecutor(selfupdate.Config{
|
||||
URLTemplate: suCfg.URLTemplate,
|
||||
Username: suCfg.Username,
|
||||
Token: suCfg.Token,
|
||||
StateDir: suCfg.StateDir,
|
||||
Runner: suRunner,
|
||||
Logger: logger,
|
||||
})
|
||||
selfUpdateMgr := selfupdate.NewManager(selfupdate.ManagerConfig{
|
||||
StateDir: suCfg.StateDir,
|
||||
RunningVersion: version,
|
||||
Dwell: time.Duration(suCfg.DwellSeconds) * time.Second,
|
||||
Runner: suRunner,
|
||||
Logger: logger,
|
||||
})
|
||||
collector.SetSelfUpdateReporter(selfUpdateMgr) // heartbeat pending-status field
|
||||
|
||||
jobsRunner := signedjobs.NewRunner(client, gate, signedjobs.ExecutorChain{wipeExec, decommExec, updateExec}, cfg.Hub.HostID, logger)
|
||||
loop.SetEnvelopeObserver(hub.MultiObserver(desiredSyncer, jobsRunner))
|
||||
|
||||
localSrv := buildLocalAPIServer(cfg, px, backupStore, observer, driveKnown, hostOps, gate, collector, intentRec, guestBindStore, formatJobStore, logger, &localTokens)
|
||||
@@ -732,6 +762,13 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
|
||||
go func() { errc <- wgLoop.Run(ctx) }()
|
||||
}
|
||||
|
||||
// TASK D1 Scenario D: if this process is a JUST-FLIPPED self-update (a pending marker names THIS
|
||||
// version), commit it after a clean dwell — but only now that core init is done (config parsed,
|
||||
// hub loop + storage watchdog + local API all started above). Runs in its own goroutine so the
|
||||
// dwell never blocks the daemon; it is NOT one of the errc siblings (it returns after commit and
|
||||
// must not end the daemon). A crash before the commit → systemd + the wrapper roll back to .prev.
|
||||
go selfUpdateMgr.MaybeCommit(ctx)
|
||||
|
||||
err = <-errc
|
||||
stop() // tear down the siblings on the first exit
|
||||
for i := 0; i < 4+localServers+lanServers+wgServers; i++ { // wait for the other goroutines
|
||||
|
||||
Reference in New Issue
Block a user