package reconcile import ( "context" "encoding/json" "sync" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) // RunState is a guest's desired/actual power state. The empty value means // "unmanaged" on the desired side (the reconciler leaves run-state alone). type RunState string const ( // RunUnspecified (the zero value) — on a DesiredGuest it means run-state is not // managed; the reconciler never starts/stops the guest for run-state reasons. RunUnspecified RunState = "" // RunRunning maps to proxmox status "running". RunRunning RunState = "running" // RunStopped maps to proxmox status "stopped". RunStopped RunState = "stopped" ) // normRun maps a raw proxmox status string to a RunState, collapsing anything // unrecognized (e.g. "") to RunUnspecified so actual-state comparison is well-defined. func normRun(status string) RunState { switch status { case "running": return RunRunning case "stopped": return RunStopped default: return RunUnspecified } } // DesiredGuest is the target state for one existing guest. Every field is // individually optional ("unmanaged") so a desired-state source can pin only what it // cares about — slice 4's planner only acts on the fields that are set. type DesiredGuest struct { VMID int // Run is the target power state; RunUnspecified leaves it alone. Run RunState // Spec, when non-nil, manages sizing. Reuses hub.GuestSpec (cores/memory/disk). // Phase A reconciles Cores and Memory via SetConfig; DiskBytes is reported but // NOT reconciled here (a rootfs grow is `pct resize`, grow-only and separate — // deferred to a later slice). Nil = sizing unmanaged. Spec *hub.GuestSpec // Description, when non-nil, manages the cosmetic `description` field (the first // proven SetConfig round-trip, slice-4 pre-check). Nil = unmanaged. Description *string // Decommission, when true, is an EXPLICIT destructive intent to tear the guest down // (slice 10A). It is the canonical destructive desired-state delta: the planner emits an // ActionDecommission, which classifies ClassDecommission → Destructive → the gate refuses it // `pending_signature` unless a verified operator signature is present. 10A never has a signer, // so a decommission is always gated (never executed); the signed execution path is 10B. An // explicit flag (not "absent from the desired list") is the safe design — a partial/empty hub // list can never silently mass-destroy guests. Decommission bool } // DesiredState is the vmid-keyed target for this host. At slice 4 the only live // source is the empty provider, so Guests is empty in production; fixtures inject it // in tests. Host-level desired state (storage manifest, etc.) arrives in later slices. type DesiredState struct { Guests map[int]DesiredGuest } // ActualGuest is one guest's observed state, read from Proxmox. type ActualGuest struct { VMID int Run RunState // SpecKnown is false when GuestConfig could not be read (the run-state from the // list is still trusted; spec/description comparisons are skipped). Mirrors the // collector's "keep run-status, omit spec" degradation. SpecKnown bool Cores int MemoryMiB int64 // proxmox LXC `memory` is MiB DiskBytes int64 // rootfs size in bytes (from the LXC list MaxDisk; for grow planning) Description string // raw (may carry PVE's trailing newline; compared via normalizers) } // ActualState is the vmid-keyed observed state for this host. type ActualState struct { Guests map[int]ActualGuest } // DesiredProvider is the seam the desired-state source plugs into. At slice 4 the // only implementation is EmptyProvider (no live source); slice 10's hub-serving path // is the real implementation. Do NOT invent a hub/local-file source here. type DesiredProvider interface { Desired(ctx context.Context) (DesiredState, error) } // EmptyProvider is the slice-4 production provider: no desired state, so reconcile is // a live no-op (the engine computes an empty action set). type EmptyProvider struct{} // Desired returns an empty desired state. func (EmptyProvider) Desired(context.Context) (DesiredState, error) { return DesiredState{Guests: map[int]DesiredGuest{}}, nil } // StaticProvider serves a fixed DesiredState — used by fixtures (and usable as a // local override later). It never mutates the value it was given. type StaticProvider struct{ State DesiredState } // Desired returns the static state. func (p StaticProvider) Desired(context.Context) (DesiredState, error) { return p.State, nil } // CachingProvider is the slice-10A production provider: a thread-safe cache of the hub-served // DesiredState plus the generation it corresponds to. The hub-sync layer (internal/desired) calls // Update when the heartbeat envelope's generation advances and a fresh fetch arrives; the engine // reads the cache via Desired each reconcile tick. Until the first Update it returns an empty // state (generation 0) — so reconcile is a live no-op exactly like EmptyProvider, with zero // mutations, which is the correct cold-start behaviour. type CachingProvider struct { mu sync.RWMutex state DesiredState gen int64 } // NewCachingProvider builds an empty provider (generation 0, no guests). func NewCachingProvider() *CachingProvider { return &CachingProvider{state: DesiredState{Guests: map[int]DesiredGuest{}}} } // Desired returns the cached state (a shallow copy of the guest map so a caller can't mutate the // cache, and a concurrent Update can't race the read). func (p *CachingProvider) Desired(context.Context) (DesiredState, error) { p.mu.RLock() defer p.mu.RUnlock() out := DesiredState{Guests: make(map[int]DesiredGuest, len(p.state.Guests))} for k, v := range p.state.Guests { out.Guests[k] = v } return out, nil } // Update replaces the cached state + generation (called by the sync layer on a generation advance). func (p *CachingProvider) Update(generation int64, state DesiredState) { p.mu.Lock() defer p.mu.Unlock() if state.Guests == nil { state.Guests = map[int]DesiredGuest{} } p.state = state p.gen = generation } // Generation returns the cached generation (the agent's view of "what I have applied from"). func (p *CachingProvider) Generation() int64 { p.mu.RLock() defer p.mu.RUnlock() return p.gen } // GuestAPI is the narrow Proxmox surface the engine needs: read actual state and // dispatch the benign-on-existing-guest mutations. *proxmox.Client satisfies it; a // fake satisfies it in tests. Every mutating call returns a UPID (or "" for the // synchronous path) per the proxmox/mutate.go contract — the engine WaitTasks a // non-empty UPID and treats "" as a clean synchronous success. type GuestAPI interface { ListLXC(ctx context.Context) ([]proxmox.Guest, error) GuestConfig(ctx context.Context, vmid int) (proxmox.GuestConfig, error) Start(ctx context.Context, vmid int) (string, error) Stop(ctx context.Context, vmid int) (string, error) SetConfig(ctx context.Context, vmid int, params map[string]string) (string, error) // ResizeLXC grows a volume (grow-only; the planner never emits a shrink). Async → UPID. ResizeLXC(ctx context.Context, vmid int, disk, size string) (string, error) // RestoreLXC restores an archive into a (fresh) vmid — the create path (slice 6). Async → UPID. RestoreLXC(ctx context.Context, opts proxmox.RestoreLXCOptions) (string, error) // DestroyLXC destroys a guest — the scratch-teardown primitive (slice 6). Async → UPID. // Destructive-class; the engine only ever issues it for an agent-tagged scratch guest // (benign by provenance) via the gate. DestroyLXC(ctx context.Context, vmid int) (string, error) // GuestStatus reads a single guest's current status (run-state poll during a restore-test). GuestStatus(ctx context.Context, vmid int) (proxmox.Guest, error) WaitTask(ctx context.Context, upid string, opts proxmox.WaitOptions) (proxmox.TaskStatus, error) // TaskStatusOnce is a single non-blocking task-status read — used by crash // recovery to learn the outcome of an op that was in flight when the agent died. TaskStatusOnce(ctx context.Context, upid string) (proxmox.TaskStatus, error) // TaskLogTail fetches up to limit trailing task-log lines — used to surface a guest-start // task's warning text (the restore-test fetches it when the start exits "WARNINGS: N", so // the advisory is reported without failing a guest that actually boots). TaskLogTail(ctx context.Context, upid string, limit int) ([]string, error) } // guestDescription decodes the (string-valued) `description` key from a GuestConfig's // raw Extra map, returning "" when absent. The value is returned raw — PVE appends a // trailing newline on read, which the normalization layer strips at comparison time. func guestDescription(cfg proxmox.GuestConfig) string { raw, ok := cfg.Extra["description"] if !ok || len(raw) == 0 { return "" } var s string if err := json.Unmarshal(raw, &s); err != nil { return "" } return s }