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
+44 -3
View File
@@ -44,7 +44,7 @@ import (
// version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.41.0"
var version = "0.42.0"
// runGuestHook is the PVE pre-start hook body (`felhom-agent guest-hook <vmid> <phase>`). On the
// pre-start phase it creates placeholder dirs for any absent bind-mount source so the guest always boots
@@ -104,6 +104,7 @@ func main() {
keyDest string
idBundlePath string
directivePath string
swapImage string
showVersion bool
)
flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)")
@@ -130,6 +131,7 @@ func main() {
flag.StringVar(&directivePath, "directive", "", "for --selftest=escrow-create: a JSON file with the non-secret DR directive (pbs repo/ns, expected fingerprint, tunnel id)")
flag.StringVar(&custID, "customer-id", "", "for --selftest=provision: the customer id — the hub config-pull target, baked into the guest's bootstrap")
flag.StringVar(&hubPassword, "hub-password", "", "for --selftest=provision: the customer's hub RETRIEVAL PASSPHRASE (SECRET) — baked into bootstrap.json so the controller pulls its config (and the customer-scoped hub key) from the hub. The customer must already exist in the hub.")
flag.StringVar(&swapImage, "image", "", "for --selftest=controller-swap: the target controller image ref (gitea.dooplex.hu/admin/felhom-controller:<semver>) — must already be pulled in the guest")
flag.StringVar(&custDomain, "customer-domain", "", "for --selftest=provision: customer domain (accepted; used by bring-up only — NOT baked into v2 bootstrap, the hub provides it)")
flag.StringVar(&custName, "customer-name", "", "for --selftest=provision: customer display name (accepted; NOT baked into v2 bootstrap)")
flag.StringVar(&custEmail, "customer-email", "", "for --selftest=provision: customer email (accepted; NOT baked into v2 bootstrap)")
@@ -189,6 +191,8 @@ func main() {
os.Exit(runSelftestEscrowConsume(context.Background(), logger, blobPath, expectedFP, keyDest))
case "identity-consume":
os.Exit(runSelftestIdentityConsume(context.Background(), logger, blobPath, keyDest))
case "controller-swap":
os.Exit(runSelftestControllerSwap(context.Background(), cfg, logger, vmid, swapImage))
}
}
@@ -712,8 +716,9 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
Disks: hostOps,
DiskGate: storageGateAdapter{gate: gate, hostID: cfg.Hub.HostID},
Guests2: px,
GuestAttach: guestBinder, // slice 10 P2: bind enrolled data drives into the guest
Intent: intent, // slice 10 P3: record enroll/eject intent for self-heal
GuestAttach: guestBinder, // slice 10 P2: bind enrolled data drives into the guest
ControllerSwap: guestBinder, // Phase 1: agentic controller update — in-guest image swap
Intent: intent, // slice 10 P3: record enroll/eject intent for self-heal
GuestBinds: guestBinds, // F9: per-guest bind record for the startup re-assert
FormatJobs: formatJobs, // F20-BUG3: detached-format job record + restart recovery
@@ -798,6 +803,42 @@ func buildVerifier(cfg config.Config, logger *slog.Logger) (reconcile.OpVerifier
return authz.New(signers, store, cfg.Hub.HostID), store, nil
}
// runSelftestControllerSwap exercises the agentic controller-update swap primitive directly (Phase 1):
// it swaps guest -vmid's controller to -image, verifies it comes up healthy, and ROLLS BACK if not.
// The target image must already be pulled in the guest (the controller pre-pulls it in the real flow).
func runSelftestControllerSwap(ctx context.Context, cfg config.Config, logger *slog.Logger, vmid int, image string) int {
if vmid <= 0 {
fmt.Fprintln(os.Stderr, "selftest=controller-swap: -vmid is required")
return 1
}
if !localapi.ValidControllerImage(image) {
fmt.Fprintln(os.Stderr, "selftest=controller-swap: -image must be gitea.dooplex.hu/admin/felhom-controller:<semver>")
return 1
}
gaMode := proxmox.RunnerMode(cfg.Privileged.Mode)
if gaMode == "" {
gaMode = proxmox.RunnerSudo
}
binder := localapi.NewGuestBinder(&proxmox.ExecRunner{Mode: gaMode, SudoPath: cfg.Privileged.SudoPath}, logger)
swapper := localapi.NewControllerSwapper(binder, "/var/lib/felhom-agent", logger)
cur, _ := swapper.CurrentImage(ctx, vmid)
fmt.Printf("=== felhom-agent %s selftest=controller-swap (vmid=%d) ===\n", version, vmid)
fmt.Printf(" current image: %s\n target image: %s\n", cur, image)
st := swapper.Swap(ctx, vmid, image)
fmt.Printf(" --- result ---\n state=%s current=%s previous=%s\n", st.State, st.Current, st.Previous)
if st.Error != "" {
fmt.Printf(" error: %s\n", st.Error)
}
if st.State == "done" {
fmt.Println("=== selftest=controller-swap OK (swapped + healthy) ===")
return 0
}
fmt.Println("=== selftest=controller-swap FAILED (rolled back if previous existed) ===")
return 1
}
// runSelftestHub validates hub config, does ONE collect + report, and prints the
// report it would send plus the envelope it got back.
func runSelftestHub(ctx context.Context, cfg config.Config, logger *slog.Logger) int {