Files
felhom-agent/internal/reconcile/plan_test.go
T
admin 1af21a6cac v0.4.0: slice 4 Phase B — reversibility gate + signed-op consuming layer
The security core of slice 4: hub-supplied intent is no longer trusted for
destructive change. The gate fronts the per-guest queue's executor, so every
mutation passes it. Reuses internal/authz for all crypto (surface untouched).

- Classifier (doc 03 §4): benign vs destructive by provenance + data-bearing-
  ness, NOT by verb. Destroy/overwrite of customer data is destructive unless
  agent-internal provenance (same-journaled-txn create, or agent-tagged scratch)
  makes it benign — and that provenance is journal-recorded, NEVER hub-sourced.
  Unknown op class fails safe to destructive.
- Reversibility gate: benign -> allowed unsigned; destructive -> requires a
  verified, role-scoped, action-bound operator signature, else pending_signature
  and never executed. Every decision audited (signal, never the guard).
- Signed-op consuming layer over authz.Verifier.Verify (locked pipeline
  untouched): role-scoping (doc 04 §4 — recovery=rotation only, operational=
  ordinary destructive + planned rotation) + op-to-action binding (op+host+
  guest+params must match the gated action).
- Signed-job orchestration: idempotency dedupe by nonce + journal-wrapped
  execution via an injected DestructiveExecutor (nil this slice — inert).
- Crash recovery (Note 1): Engine.Recover consumes the journal InFlight() set at
  startup (resume-or-rollback) — covers an op that crashed after the POST and
  before its terminal record, which idempotency dedupe alone cannot. Added
  TaskStatusOnce to the GuestAPI seam. Wired into daemon startup.
- Note 2: memory comparison canonicalized to MiB (desiredMemoryMiB) so a
  non-MiB-aligned MemoryBytes converges in one pass, not perpetual drift.
- Daemon: builds the verifier from config signers (none = nil verifier, the
  common slice-4 state), the gate (+SlogAudit), runs Recover before mutating.

Adversarial matrix proven against the REAL authz.Verifier with in-test-minted
SSHSIGs (framing replicated in reconcile's test binary; authz untouched, no
signing added to the verify-only package): unsigned job + unsigned desired-state
delta -> pending_signature; unknown signer/expired/replay-across-restart/wrong
host -> typed authz rejections; wrong guest/op/params -> binding_mismatch;
recovery key on ordinary destructive -> role_denied; hub-supplied scratch tag
ignored -> refused; valid+role+target+fresh nonce -> accepted then replay
rejected. Full module race-clean + vet-clean on the Linux build server.

Inert this slice: no destructive deltas served until slice 10; the destructive
path is classified, gated, and tested but not wired to live execution.

CHECKPOINT: Phase B complete (slice 4 done). Awaiting validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:56:20 +02:00

229 lines
8.1 KiB
Go

package reconcile
import (
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
func sp(s string) *string { return &s }
func mib(n int64) int64 { return n * bytesPerMiB }
// desired/actual builders keep the table compact.
func desired(gs ...DesiredGuest) DesiredState {
m := map[int]DesiredGuest{}
for _, g := range gs {
m[g.VMID] = g
}
return DesiredState{Guests: m}
}
func actual(gs ...ActualGuest) ActualState {
m := map[int]ActualGuest{}
for _, g := range gs {
m[g.VMID] = g
}
return ActualState{Guests: m}
}
func TestPlan_RunStateStartAndStop(t *testing.T) {
// stopped -> running
got := Plan(
desired(DesiredGuest{VMID: 100, Run: RunRunning}),
actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true}),
nil)
mustActions(t, got, Action{VMID: 100, Kind: ActionStart})
// running -> stopped
got = Plan(
desired(DesiredGuest{VMID: 100, Run: RunStopped}),
actual(ActualGuest{VMID: 100, Run: RunRunning, SpecKnown: true}),
nil)
mustActions(t, got, Action{VMID: 100, Kind: ActionStop})
// already matches -> nothing
got = Plan(
desired(DesiredGuest{VMID: 100, Run: RunRunning}),
actual(ActualGuest{VMID: 100, Run: RunRunning, SpecKnown: true}),
nil)
mustActions(t, got)
}
func TestPlan_SpecDrift(t *testing.T) {
// cores change
got := Plan(
desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 4, MemoryBytes: mib(2048)}}),
actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}),
nil)
mustActions(t, got, Action{VMID: 100, Kind: ActionSetConfig, Params: map[string]string{"cores": "4"}})
// memory change (bytes desired -> MiB param)
got = Plan(
desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 2, MemoryBytes: mib(4096)}}),
actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}),
nil)
mustActions(t, got, Action{VMID: 100, Kind: ActionSetConfig, Params: map[string]string{"memory": "4096"}})
// no spec drift -> nothing
got = Plan(
desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 2, MemoryBytes: mib(2048)}}),
actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}),
nil)
mustActions(t, got)
}
func TestPlan_MemoryNonAlignedConverges(t *testing.T) {
// Note-2 guard: a desired MemoryBytes that is NOT a clean MiB multiple must not
// cause perpetual drift. We compare in MiB and write the SAME MiB we compared, so it
// settles in one pass.
desiredBytes := int64(2049)*bytesPerMiB + 500000 // 2049 MiB + change → floors to 2049
d := desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 2, MemoryBytes: desiredBytes}})
// First pass: actual is 2048 MiB → one SetConfig memory=2049.
got := Plan(d, actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}), nil)
mustActions(t, got, Action{VMID: 100, Kind: ActionSetConfig, Params: map[string]string{"memory": "2049"}})
// Apply it: actual becomes 2049 MiB. Re-plan against the SAME desired → no action.
if got2 := Plan(d, actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2049}), nil); len(got2) != 0 {
t.Fatalf("non-MiB-aligned memory did not converge (perpetual drift): %+v", got2)
}
}
func TestPlan_DiskNotReconciled(t *testing.T) {
// DiskBytes differs but is intentionally not reconciled (pct resize, later slice).
got := Plan(
desired(DesiredGuest{VMID: 100, Spec: &hub.GuestSpec{Cores: 2, MemoryBytes: mib(2048), DiskBytes: 1 << 40}}),
actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048}),
nil)
mustActions(t, got)
}
func TestPlan_DescriptionNormalizedNoFalseDrift(t *testing.T) {
// PVE returns the description with a trailing newline; desired has none. Must NOT
// be planned as drift.
got := Plan(
desired(DesiredGuest{VMID: 100, Description: sp("felhom-managed")}),
actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Description: "felhom-managed\n"}),
nil)
mustActions(t, got)
// A genuine description change IS planned.
got = Plan(
desired(DesiredGuest{VMID: 100, Description: sp("new-desc")}),
actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Description: "old-desc\n"}),
nil)
mustActions(t, got, Action{VMID: 100, Kind: ActionSetConfig, Params: map[string]string{"description": "new-desc"}})
}
func TestPlan_UnmanagedFieldsProduceNothing(t *testing.T) {
// Run unspecified, Spec nil, Description nil -> the reconciler leaves it alone even
// though actual differs from "defaults".
got := Plan(
desired(DesiredGuest{VMID: 100}),
actual(ActualGuest{VMID: 100, Run: RunRunning, SpecKnown: true, Cores: 8, MemoryMiB: 9999, Description: "whatever"}),
nil)
mustActions(t, got)
}
func TestPlan_SpecUnknownSkipsConfigButKeepsRunState(t *testing.T) {
// GuestConfig read failed (SpecKnown=false): never write a config we couldn't read,
// but run-state is still comparable from the list.
got := Plan(
desired(DesiredGuest{VMID: 100, Run: RunRunning, Spec: &hub.GuestSpec{Cores: 4, MemoryBytes: mib(4096)}, Description: sp("x")}),
actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: false}),
nil)
mustActions(t, got, Action{VMID: 100, Kind: ActionStart})
}
func TestPlan_DesiredAbsentInActualSkipped(t *testing.T) {
// A guest desired but not present would be provisioning (slice 7) — not a slice-4
// action. And a guest present but not desired would be a destroy (gated, slice 10).
got := Plan(
desired(DesiredGuest{VMID: 200, Run: RunRunning}),
actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true}),
nil)
mustActions(t, got)
}
func TestPlan_CombinedConfigBeforeRunState(t *testing.T) {
// cores + memory + description + run change: one SetConfig (all params) THEN the
// run-state action, both on the same vmid (the queue serializes them).
got := Plan(
desired(DesiredGuest{VMID: 100, Run: RunRunning,
Spec: &hub.GuestSpec{Cores: 4, MemoryBytes: mib(4096)}, Description: sp("new")}),
actual(ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true, Cores: 2, MemoryMiB: 2048, Description: "old\n"}),
nil)
if len(got) != 2 {
t.Fatalf("want 2 actions, got %d: %+v", len(got), got)
}
if got[0].Kind != ActionSetConfig {
t.Errorf("first action should be SetConfig, got %s", got[0].Kind)
}
for _, k := range []string{"cores", "memory", "description"} {
if _, ok := got[0].Params[k]; !ok {
t.Errorf("SetConfig params missing %q: %v", k, got[0].Params)
}
}
if got[1].Kind != ActionStart {
t.Errorf("second action should be Start, got %s", got[1].Kind)
}
}
func TestPlan_EmptyDesiredNoActions(t *testing.T) {
// The slice-4 production case: empty desired -> zero actions regardless of actual.
got := Plan(
DesiredState{Guests: map[int]DesiredGuest{}},
actual(ActualGuest{VMID: 100, Run: RunRunning, SpecKnown: true}),
nil)
mustActions(t, got)
}
func TestPlan_DeterministicVMIDOrder(t *testing.T) {
got := Plan(
desired(
DesiredGuest{VMID: 300, Run: RunRunning},
DesiredGuest{VMID: 100, Run: RunRunning},
DesiredGuest{VMID: 200, Run: RunRunning},
),
actual(
ActualGuest{VMID: 100, Run: RunStopped, SpecKnown: true},
ActualGuest{VMID: 200, Run: RunStopped, SpecKnown: true},
ActualGuest{VMID: 300, Run: RunStopped, SpecKnown: true},
),
nil)
if len(got) != 3 || got[0].VMID != 100 || got[1].VMID != 200 || got[2].VMID != 300 {
t.Fatalf("actions not sorted by vmid: %+v", got)
}
}
// mustActions asserts the planned actions equal want (vmid+kind+params), ignoring
// Reason (debug-only).
func mustActions(t *testing.T, got []Action, want ...Action) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("got %d actions, want %d: %+v", len(got), len(want), got)
}
for i := range want {
if got[i].VMID != want[i].VMID || got[i].Kind != want[i].Kind {
t.Errorf("action[%d] = {vmid:%d kind:%s}, want {vmid:%d kind:%s}",
i, got[i].VMID, got[i].Kind, want[i].VMID, want[i].Kind)
}
if !sameParams(got[i].Params, want[i].Params) {
t.Errorf("action[%d] params = %v, want %v", i, got[i].Params, want[i].Params)
}
}
}
func sameParams(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range b {
if a[k] != v {
return false
}
}
return true
}