Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions CONTROLLER.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,21 @@ in. Any change requires re-opening the discussion.
gap-filling.
- **Comparison:** loose. Only manifest-specified fields are tracked.
Provider-set defaults are unmanaged.
- **VM observation:** Get and List read authoritative Proxmox configuration,
including configured CPU/RAM, disks, NICs, agent, native cloud-init settings,
firmware, EFI, and PCI passthrough. Configuration-read/decoding failures are
errors, not incomplete observations; numeric JSON strings are accepted.
- **VM comparison:** declared disk/NIC slots are matched by name; undeclared
slots remain unmanaged. Equivalent binary disk-size units and MAC address
casing compare equal. Creation/routing inputs (`context`, `template`,
`image`, `cloudImage`, `startOnCreate`) and unobservable cloud-init inputs
(`password`, `packages`, `runcmd`) are excluded from live drift, not echoed
into observations. They remain in desired manifests and desired-to-desired
dry-run diffs; changing creation/first-boot inputs requires replacement.
- **Apply on existing atomic resource (e.g. `VirtualMachine`):** update in
place for the fields the provider can change live — for `VirtualMachine`:
memory, CPU (cores/sockets), and disk **growth**. Non-resizable differences
(template, networks, cloud-init) are not mutated; they surface as drift and
(networks, native cloud-init settings) are not mutated; they surface as drift and
still require delete + re-apply. Disk **shrink** is rejected (Proxmox can't
shrink) — delete + re-apply to size down. Re-applying an unchanged spec is a
no-op (identical config, disks already at size).
Expand Down Expand Up @@ -362,9 +373,12 @@ destructive guardrails enforced.
`internal/controller/server/drift.go` walks the desired spec and
surfaces only the keys where desired ≠ observed; provider-set
defaults are unmanaged.
- [x] VirtualMachine drift on Get: works generically via the loose
comparison (proxmox `VMToResource` returns spec keys that overlap
with the manifest, e.g. `cpu.cores`, `memory.size`).
- [x] VirtualMachine drift on Get/List/Watch and periodic reconciliation uses
the same provider-owned comparison projection over native configuration.
Missing declared settings still drift; only explicit creation-only,
routing, write-only, and first-boot execution inputs are excluded.
Failed configuration reads surface as errors, including name-scoped
Watch (an outage must not masquerade as deletion).
- [x] Cluster structural drift on Get: the k3s provider synthesizes
`spec.nodes.controlPlane.count` and each `spec.nodes.workers[*]
.count` from the *actual* children list, so post-apply Gets reveal
Expand Down
8 changes: 8 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ plugin protocol → Terraform/OpenTofu host → run-anywhere Linux daemon
(OIDC slice) and CUE-WASM validation are parked behind that. (Mobile
layout has since shipped.)

**Proxmox VM observation correction (2026-10-08):** Get/List now decode numeric
configuration strings and observe native managed settings instead of partial
runtime inventory. Get/List/Watch and reconciliation share provider-owned drift
semantics: declared disk/NIC slots matched by name, equivalent sizes normalized,
creation/write-only inputs excluded, and read outages reported rather than
mistaken for deletion. Root/plugin tests and a read-only homelab Get/List smoke
on `dev-system` pass with zero drift; no VM configuration was changed.

## Homelab completion (COMPLETE — 2026-07-12)

Backend-vs-vision gap audit for running the full homelab through openctl
Expand Down
12 changes: 6 additions & 6 deletions docs/homelab-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ sees every VM on the node (so the collision guard can't be fooled) and its `-o
yaml` surfaces the **vmid** — the field that proves an in-place resize did not
recreate the VM.

The verify VM is created **stopped** on purpose. A resize test doesn't need a
booted guest, it's faster, and it keeps memory observation reliable: `proxmox
get vms` reports a *running* VM's live `MaxMem`, which lags a config change until
reboot — so a running VM would show the old memory even though the config was
correctly updated. (This nuance was found on the first real-hardware run and is
why the harness now creates stopped.)
The verify VM is created **stopped** on purpose: booting a guest is unnecessary
for a resize test and makes the harness slower. Get/List now report configured
CPU/RAM from Proxmox's configuration API, even while a running guest still uses
its pre-reboot allocation. Guest-visible resources can therefore differ until
the guest applies the change or reboots; the configuration API is authoritative
for validating the resize.

## Safety

Expand Down
12 changes: 11 additions & 1 deletion internal/controller/manifests/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,9 @@ func TestRepoStartPeriodicPullReconcilesRemoteCommits(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git binary not on PATH; skipping git-dependent test")
}
ctx := t.Context() // canceled when the test ends, stopping the pull goroutine
// T.Context cancels before cleanup; wait for any git command before canceling.
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)

base := t.TempDir()
bare := filepath.Join(base, "remote.git")
Expand Down Expand Up @@ -362,6 +364,14 @@ func TestRepoStartPeriodicPullReconcilesRemoteCommits(t *testing.T) {
t.Fatalf("NewRepo: %v", err)
}

t.Cleanup(func() {
if err := repo.lock.acquire(context.Background()); err != nil {
t.Errorf("wait for repository operations: %v", err)
return
}
cancel()
repo.lock.release()
})
var mu sync.Mutex
changes := 0
repo.StartPeriodicPull(ctx, 30*time.Millisecond, func(context.Context) error {
Expand Down
3 changes: 2 additions & 1 deletion internal/controller/operations/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package operations
import (
"context"
"fmt"
"slices"
"sort"
"strings"
)
Expand Down Expand Up @@ -115,7 +116,7 @@ func RunGraph(ctx context.Context, concurrency int, tasks []Task) error {
continue // drain remaining in-flight, launch nothing new
}

newlyReady := ready[:len(ready):len(ready)]
newlyReady := slices.Clip(ready)
for _, dep := range dependents[res.id] {
indegree[dep]--
if indegree[dep] == 0 {
Expand Down
17 changes: 17 additions & 0 deletions internal/controller/providers/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,23 @@ type ObservedOnly interface {
ObservedOnlyKinds() []string
}

// DriftProjector supplies provider-specific observation comparison semantics.
// Creation-only and write-only inputs cannot be compared to live state, and
// named collections may need alignment. Projections must not mutate either
// spec or substitute desired values for missing live configuration.
type DriftProjector interface {
DriftSpecs(kind string, desired, observed map[string]any) (map[string]any, map[string]any)
}

// DriftSpecs projects desired-versus-live comparison inputs. Manifest editing
// and dry-run desired-versus-desired diffs must retain the original specs.
func DriftSpecs(p Provider, kind string, desired, observed map[string]any) (map[string]any, map[string]any) {
if projector, ok := p.(DriftProjector); ok {
return projector.DriftSpecs(kind, desired, observed)
}
return desired, observed
}

// DryRunner is an optional provider capability for previewing what an
// Apply would do without performing it. Composite providers (k3s Cluster
// → VMs) implement this to surface per-child actions and the gates the
Expand Down
12 changes: 6 additions & 6 deletions internal/controller/providers/proxmox/apply_observed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,8 @@ import (
// nil result here regresses that contract (and would fail the shared
// providertest conformance battery once proxmox is bound to it).
//
// This drives the apply-on-existing path: the VM "vm0" already exists, so the
// handler's applyVM takes the no-op branch (returns observed state, no clone),
// and Apply then reads it back. A permissive catch-all keeps incidental calls (config, guest-agent
// IP) benign so the test asserts only the return-value contract.
// The desired manifest only specifies placement; the returned resource must
// still expose independently observed configured hardware.
func TestApplyReturnsObservedVM(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
Expand All @@ -28,6 +26,8 @@ func TestApplyReturnsObservedVM(t *testing.T) {
_, _ = io.WriteString(w, `{"data":[{"node":"pve1","status":"online"}]}`)
case "/api2/json/nodes/pve1/qemu":
_, _ = io.WriteString(w, `{"data":[{"vmid":100,"name":"vm0","status":"running","node":"pve1","template":0}]}`)
case "/api2/json/nodes/pve1/qemu/100/config":
_, _ = io.WriteString(w, `{"data":{"cores":4,"sockets":1,"memory":"4096"}}`)
default:
// config / resize / agent-network-get / etc. — benign success so
// the update path completes without hitting an unhandled route.
Expand All @@ -52,7 +52,7 @@ func TestApplyReturnsObservedVM(t *testing.T) {
if got.Kind != kindVM || got.Metadata.Name != "vm0" {
t.Errorf("Apply returned %s/%s, want %s/vm0", got.Kind, got.Metadata.Name, kindVM)
}
if got.APIVersion == "" {
t.Error("Apply result APIVersion is empty; providers must stamp it")
if got.Spec["cpu"].(map[string]any)["cores"] != 4 || got.Spec["memory"].(map[string]any)["size"] != 4096 {
t.Errorf("Apply returned incomplete or invented hardware: %v", got.Spec)
}
}
4 changes: 4 additions & 0 deletions internal/controller/providers/proxmox/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ func (f *fakeProxmox) newServer(t *testing.T) *httptest.Server {
newid, _ := strconv.Atoi(r.Form.Get("newid"))
f.vms[r.Form.Get("name")] = newid
_ = json.NewEncoder(w).Encode(map[string]any{"data": ""})
case strings.HasSuffix(path, "/config") && r.Method == http.MethodGet:
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{
"cores": 1, "sockets": 1, "memory": "512",
}})
case r.Method == http.MethodDelete && strings.Contains(path, "/qemu/"):
vmid, _ := strconv.Atoi(path[strings.LastIndex(path, "/")+1:])
for name, id := range f.vms {
Expand Down
15 changes: 9 additions & 6 deletions internal/controller/providers/proxmox/multicontext_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,15 @@ func TestApplyUnknownContextErrors(t *testing.T) {
// configured endpoint, merged into one result set.
func TestListMergesAcrossEndpoints(t *testing.T) {
srvA := mockProxmox(t, map[string]string{
"/api2/json/nodes": `{"data":[{"node":"pveA","status":"online"}]}`,
"/api2/json/nodes/pveA/qemu": `{"data":[{"vmid":100,"name":"web-01","status":"running","node":"pveA","template":0}]}`,
"/api2/json/nodes": `{"data":[{"node":"pveA","status":"online"}]}`,
"/api2/json/nodes/pveA/qemu": `{"data":[{"vmid":100,"name":"web-01","status":"running","node":"pveA","template":0}]}`,
"/api2/json/nodes/pveA/qemu/100/config": `{"data":{"cores":2,"sockets":1,"memory":"2048"}}`,
})
defer srvA.Close()
srvB := mockProxmox(t, map[string]string{
"/api2/json/nodes": `{"data":[{"node":"pveB","status":"online"}]}`,
"/api2/json/nodes/pveB/qemu": `{"data":[{"vmid":200,"name":"cache-01","status":"running","node":"pveB","template":0}]}`,
"/api2/json/nodes": `{"data":[{"node":"pveB","status":"online"}]}`,
"/api2/json/nodes/pveB/qemu": `{"data":[{"vmid":200,"name":"cache-01","status":"running","node":"pveB","template":0}]}`,
"/api2/json/nodes/pveB/qemu/200/config": `{"data":{"cores":2,"sockets":1,"memory":"2048"}}`,
})
defer srvB.Close()

Expand Down Expand Up @@ -115,8 +117,9 @@ func TestGetRoutesToOwningEndpoint(t *testing.T) {
})
defer srvA.Close()
srvB := mockProxmox(t, map[string]string{
"/api2/json/nodes": `{"data":[{"node":"pveB","status":"online"}]}`,
"/api2/json/nodes/pveB/qemu": `{"data":[{"vmid":200,"name":"cache-01","status":"running","node":"pveB","template":0}]}`,
"/api2/json/nodes": `{"data":[{"node":"pveB","status":"online"}]}`,
"/api2/json/nodes/pveB/qemu": `{"data":[{"vmid":200,"name":"cache-01","status":"running","node":"pveB","template":0}]}`,
"/api2/json/nodes/pveB/qemu/200/config": `{"data":{"cores":2,"sockets":1,"memory":"2048"}}`,
})
defer srvB.Close()

Expand Down
14 changes: 11 additions & 3 deletions internal/controller/providers/proxmox/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/openctl/openctl/internal/controller/providers"
"github.com/openctl/openctl/pkg/protocol"
pmhandler "github.com/openctl/openctl/pkg/proxmox/handler"
pmresources "github.com/openctl/openctl/pkg/proxmox/resources"
)

const (
Expand Down Expand Up @@ -147,6 +148,14 @@ func (p *Provider) Kinds() []string { return []string{kindVM, kindNode} }
// API rather than provisioning them — they can never be in applied_manifests.
func (p *Provider) ObservedOnlyKinds() []string { return []string{kindNode} }

// DriftSpecs compares observable VM settings, excluding creation-only inputs.
func (p *Provider) DriftSpecs(kind string, desired, observed map[string]any) (map[string]any, map[string]any) {
if kind == kindVM {
return pmresources.ComparableVMSpecs(desired, observed)
}
return desired, observed
}

// Actions implements providers.Actioner. VirtualMachine supports the
// standard power-lifecycle set plus "console" (opens Proxmox noVNC in
// a new tab); ProxmoxNode has no runtime actions.
Expand Down Expand Up @@ -216,9 +225,8 @@ func (p *Provider) DoAction(ctx context.Context, kind, name, action string) (*pr
}
}

// Apply creates a VM if missing; otherwise returns the observed state
// without mutating (per the no-op-on-existing rule). ProxmoxNode is
// observed-only and rejects Apply.
// Apply creates a missing VM or resizes an existing VM's CPU, memory, and disks,
// then returns observed state. ProxmoxNode is observed-only and rejects Apply.
func (p *Provider) Apply(ctx context.Context, manifest *protocol.Resource) (*protocol.Resource, error) {
if manifest.Kind == kindNode {
return nil, fmt.Errorf("%s is observed-only; cannot be applied", kindNode)
Expand Down
8 changes: 6 additions & 2 deletions internal/controller/providers/proxmox/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ func TestProviderChildrenOfNodeReturnsVMs(t *testing.T) {
"/api2/json/nodes/pve2/qemu": `{"data":[
{"vmid":200,"name":"cache-01","status":"running","node":"pve2","template":0}
]}`,
"/api2/json/nodes/pve1/qemu/100/config": `{"data":{"cores":2,"sockets":1,"memory":"2048"}}`,
"/api2/json/nodes/pve1/qemu/101/config": `{"data":{"cores":2,"sockets":1,"memory":"2048"}}`,
"/api2/json/nodes/pve2/qemu/200/config": `{"data":{"cores":2,"sockets":1,"memory":"2048"}}`,
})
defer srv.Close()

Expand Down Expand Up @@ -161,8 +164,9 @@ func TestProviderChildrenOfNodeReturnsVMs(t *testing.T) {

func TestProviderListReturnsVMs(t *testing.T) {
srv := mockProxmox(t, map[string]string{
"/api2/json/nodes": `{"data":[{"node":"pve1","status":"online"}]}`,
"/api2/json/nodes/pve1/qemu": `{"data":[{"vmid":100,"name":"web-01","status":"running","node":"pve1","template":0},{"vmid":200,"name":"tpl-x","status":"stopped","node":"pve1","template":1}]}`,
"/api2/json/nodes": `{"data":[{"node":"pve1","status":"online"}]}`,
"/api2/json/nodes/pve1/qemu": `{"data":[{"vmid":100,"name":"web-01","status":"running","node":"pve1","template":0},{"vmid":200,"name":"tpl-x","status":"stopped","node":"pve1","template":1}]}`,
"/api2/json/nodes/pve1/qemu/100/config": `{"data":{"cores":2,"sockets":1,"memory":"2048"}}`,
})
defer srv.Close()

Expand Down
3 changes: 2 additions & 1 deletion internal/controller/reconciler/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@ func (r *Reconciler) reconcileOne(ctx context.Context, ref manifests.Ref) {
return
}

drifted := !specsEqual(desired.Spec, observed.Spec)
desiredSpec, observedSpec := providers.DriftSpecs(p, ref.Kind, desired.Spec, observed.Spec)
drifted := !specsEqual(desiredSpec, observedSpec)
reason := ""
if drifted {
reason = "spec drift"
Expand Down
Loading
Loading