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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user