v0.42.0: agentic controller update — in-guest image swap + rollback (Phase 1)

New local-API POST /controller/swap (+ GET /controller/swap/status), withGuest-
scoped: the agent records the previous image, confirms the target is present,
rewrites /etc/felhom-controller-image, restarts felhom-controller-bootstrap.service,
verifies the new controller is healthy (docker inspect, <=90s), and ROLLS BACK to
the previous image if not. Single-flight per guest; strict image-ref validation;
crash-safety state file. GuestBinder.GuestExec is the pct-exec seam.
--selftest=controller-swap exercises it directly.

Tests: happy/rollback-on-unhealthy(+red-proof)/image-absent/no-healthcheck/
bad-image-400/single-flight-409.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtXesNa2LGbMmE4DNL6SE7
This commit is contained in:
2026-06-26 21:26:12 +02:00
parent 4725396c81
commit 6f14b66191
6 changed files with 672 additions and 6 deletions
+21 -3
View File
@@ -84,6 +84,11 @@ type Options struct {
// GuestAttach binds an enrolled user-data drive's felhom-data namespace into the guest (slice 10
// P2, Model A). OPTIONAL — when nil, POST /disks/guest-attach reports "not configured".
GuestAttach GuestAttacher
// ControllerSwap runs guest commands (pct exec) for the agentic controller-update swap (Phase 1).
// OPTIONAL — when nil, POST /controller/swap reports "not configured". Satisfied by *GuestBinder.
ControllerSwap GuestExecutor
// ControllerSwapStateDir holds the per-guest swap state file (crash-safety). "" → /var/lib/felhom-agent.
ControllerSwapStateDir string
// Intent records drive enroll/eject intent for the self-heal watchdog (slice 10 P3). OPTIONAL —
// when nil, no intent is recorded (self-heal runs ungated).
Intent IntentRecorder
@@ -178,6 +183,11 @@ type Server struct {
jobsMu sync.Mutex
jobs map[int]*backupJob // per-guest backup job state (slice 8B)
// agentic controller update (Phase 1): the swapper + per-guest single-flight gate.
swap *ControllerSwapper
swapMu sync.Mutex
swapInFlight map[int]bool
baseCtx context.Context // for fire-and-forget backups; set in Run
}
@@ -215,12 +225,16 @@ func NewServer(o Options) (*Server, error) {
guestBinds: o.GuestBinds,
formatJobs: o.FormatJobs,
host: o.HostReader,
hostMetrics: o.HostMetrics,
hostID: o.HostID,
jobs: map[int]*backupJob{},
hostMetrics: o.HostMetrics,
hostID: o.HostID,
jobs: map[int]*backupJob{},
swapInFlight: map[int]bool{},
}
s.reresolveWipe = s.reresolveDurableForWipe
s.deviceDurableID = storage.DeviceDurableID
if o.ControllerSwap != nil {
s.swap = NewControllerSwapper(o.ControllerSwap, o.ControllerSwapStateDir, o.Logger)
}
return s, nil
}
@@ -248,6 +262,10 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /disks/guest-attach", s.withGuest(s.handleDiskGuestAttach))
// Guest reboot (slice 10 P2 activation): user-triggered restart to activate pending drive binds.
mux.HandleFunc("POST /guest/reboot", s.withGuest(s.handleGuestReboot))
// agentic controller update (Phase 1): in-guest image swap + rollback, owned by the agent.
mux.HandleFunc("POST /controller/swap", s.withGuest(s.handleControllerSwap))
mux.HandleFunc("GET /controller/swap/status", s.withGuest(s.handleControllerSwapStatus))
return mux
}