Files
egress-proxies-operator/internal/provider/proxmox/get_test.go
T
kacerrandClaude Opus 5.5 2f89c1fffb proxmox: Delete with seed cleanup, health Checker, CRD type comment
Delete stops and destroys a VM of ours under the per-name lock, then
deletes its seed on the VM's node, or on every node when no VM of ours
was left. A hibernated VM of ours is Permanent, as in Create. The
Checker verifies each node concurrently (version, node status, ISO
storage, templates) and adds CertificateInvalid, CredentialsUnavailable,
StorageNotFound and TemplateNotFound to the shared reasons. The
ProviderHealth type comment gains proxmox.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 20:14:14 +00:00

535 lines
19 KiB
Go

package proxmox
import (
"errors"
"slices"
"strings"
"sync"
"testing"
"time"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// mutateOn changes the fake's state under its lock at the start of every
// call to method: a VM changing between two of Get's calls.
func mutateOn(f *fakeAPI, method string, fn func()) {
f.hookOn(method, func() error {
f.mu.Lock()
defer f.mu.Unlock()
fn()
return nil
})
}
// capturedIfaces is the agent's answer from the Phase 0 guest: lo and ens18
// with 192.168.0.150.
func capturedIfaces(t *testing.T) []agentInterface {
t.Helper()
return decodeData[struct {
Result []agentInterface `json:"result"`
}](t, "agent_network_get_interfaces").Result
}
// assertGetFails runs Get on fx.id(), checks the error's class and returns
// its text.
func assertGetFails(t *testing.T, fx *fixture, class error) string {
t.Helper()
inst, err := fx.p.Get(fx.ctx, fx.id())
if inst != nil {
t.Errorf("Get = %+v, want nil", inst)
}
assertProviderError(t, err, "get", class)
return err.Error()
}
func TestGet_missingVMIsNotFound(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID+1, testName, "running")
assertGetFails(t, fx, provider.ErrNotFound)
lines := fx.logs.matching(`"msg"="proxmox vm not found"`)
if len(lines) != 1 || !strings.Contains(lines[0], `"vmid"=150042`) || !strings.Contains(lines[0], `"name"="`+testName+`"`) {
t.Errorf("not-found lines = %v, want one with vmid and name", lines)
}
if got := fx.fake.callLog(); !slices.Equal(got, []string{"ClusterResources"}) {
t.Errorf("calls = %v, want only the resources listing", got)
}
}
func TestGet_containerAtVMIDIsNotFound(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running").kind = "lxc"
assertGetFails(t, fx, provider.ErrNotFound)
}
func TestGet_nameMismatchIsNotFound(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, "proxy-someoneelse00000", "running")
msg := assertGetFails(t, fx, provider.ErrNotFound)
if !strings.Contains(msg, "reused") {
t.Errorf("error = %s, want it to say the VMID was reused", msg)
}
if n := fx.fake.callCount("VMStatus"); n != 0 {
t.Errorf("VMStatus calls = %d, want 0 for a VM that is not ours", n)
}
if lines := fx.logs.matching(`"msg"="proxmox vm not found"`); len(lines) != 1 {
t.Errorf("not-found lines = %v, want one", lines)
}
}
func TestGet_unmarkedIsNotFound(t *testing.T) {
t.Parallel()
tests := []struct {
name string
description string
}{
{name: "no description"},
{name: "managed false", description: "proxy-operator-managed=false\nproxy-operator-uid=" + testUID},
{name: "admin notes only", description: "hand made, do not touch"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
vm := fx.fake.addVM(testVMID, testName, "running")
if tc.description != "" {
vm.config["description"] = tc.description
}
msg := assertGetFails(t, fx, provider.ErrNotFound)
if !strings.Contains(msg, "no proxy-operator mark") {
t.Errorf("error = %s, want it to name the missing mark", msg)
}
})
}
}
// TestGet_otherVMWithOurNameIsIgnored replaces the plan's
// TestGet_twoVMsWithOurNameIsPermanent: spec entries 2 and 9 address Get by
// VMID, and only Create treats a duplicate name as Permanent.
func TestGet_otherVMWithOurNameIsIgnored(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "stopped")
fx.addOurVM(testVMID+7, testName, "running")
if inst := fx.get(fx.id()); inst.State != provider.StateStopped || inst.ID != fx.id() {
t.Errorf("Get = %+v, want the VM at the ID's VMID, Stopped", inst)
}
}
func TestGet_vmOnAnotherNodeIsFound(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
vm := fx.addOurVM(testVMID, testName, "running")
vm.node = "pv1-2"
vm.ifaces = capturedIfaces(t)
inst := fx.get(fx.id())
if inst.State != provider.StateRunning || inst.IP != "192.168.0.150" {
t.Errorf("Get = %+v, want Running with 192.168.0.150 read from pv1-2", inst)
}
if lines := fx.logs.matching(`"msg"="proxmox vm fetched"`); len(lines) != 1 || !strings.Contains(lines[0], `"node"="pv1-2"`) {
t.Errorf("fetched lines = %v, want the resolved node", lines)
}
}
func TestGet_runningWithoutIPIsProvisioning(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running")
if inst := fx.get(fx.id()); inst.State != provider.StateProvisioning || inst.IP != "" {
t.Errorf("Get = %+v, want Provisioning with no IP", inst)
}
}
func TestGet_runningWithIPIsRunning(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running").ifaces = capturedIfaces(t)
inst := fx.get(fx.id())
if inst.State != provider.StateRunning || inst.IP != "192.168.0.150" || inst.ID != fx.id() {
t.Errorf("Get = %+v, want Running with 192.168.0.150", inst)
}
lines := fx.logs.matching(`"msg"="proxmox vm fetched"`)
if len(lines) != 1 {
t.Fatalf("fetched lines = %v, want one", lines)
}
for _, kv := range []string{`"provider"="proxmox-home"`, `"vmid"=150042`, `"name"="` + testName + `"`, `"node"="pv1-1"`, `"state"="Running"`, `"ip"="192.168.0.150"`, `"uid"="` + testUID + `"`} {
if !strings.Contains(lines[0], kv) {
t.Errorf("fetched line lacks %s: %s", kv, lines[0])
}
}
}
func TestGet_runningMakesFourCalls(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running").ifaces = capturedIfaces(t)
fx.get(fx.id())
want := []string{"ClusterResources", "VMConfig", "VMStatus", "AgentNetworkInterfaces"}
if got := fx.fake.callLog(); !slices.Equal(got, want) {
t.Errorf("calls = %v, want %v", got, want)
}
}
func TestGet_agentNotRunningIsProvisioning(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running").ifaces = capturedIfaces(t)
fx.fake.agentNotRunning = true
if inst := fx.get(fx.id()); inst.State != provider.StateProvisioning || inst.IP != "" {
t.Errorf("Get = %+v, want Provisioning while the agent is not running", inst)
}
}
// TestGet_agentMessagesTable is spec entry 7's table, with the agent's
// answers taken from the captures.
func TestGet_agentMessagesTable(t *testing.T) {
t.Parallel()
tests := []struct {
name string
agentErr error
ifaces []agentInterface
wantState provider.InstanceState
wantClass error
}{
{name: "200 with a routable IPv4", ifaces: capturedIfaces(t), wantState: provider.StateRunning},
{
name: "200 without an IPv4",
ifaces: []agentInterface{{Name: "lo", IPAddresses: []agentIP{{Address: "127.0.0.1", Type: "ipv4"}}}, {Name: "ens18", IPAddresses: []agentIP{{Address: "fe80::1", Type: "ipv6"}}}},
wantState: provider.StateProvisioning,
},
{name: "agent not running", agentErr: loadAPIError(t, "err_agent_not_running"), wantState: provider.StateProvisioning},
{name: "no agent configured", agentErr: loadAPIError(t, "err_agent_vm_stopped"), wantState: provider.StateStopped},
{name: "VM stopped since the status read", agentErr: loadAPIError(t, "err_agent_vm_stopped_agent_enabled"), wantClass: provider.ErrTransient},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running").ifaces = tc.ifaces
if tc.agentErr != nil {
fx.fake.failWith("AgentNetworkInterfaces", tc.agentErr)
}
if tc.wantClass != nil {
assertGetFails(t, fx, tc.wantClass)
return
}
inst := fx.get(fx.id())
if inst.State != tc.wantState {
t.Errorf("Get state = %s, want %s", inst.State, tc.wantState)
}
if (inst.State == provider.StateRunning) != (inst.IP != "") {
t.Errorf("Get = %+v: an IP goes with Running and only with Running", inst)
}
})
}
}
func TestGet_agentOtherErrorIsTransient(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
}{
{name: "forbidden", err: loadAPIError(t, "err_forbidden")},
{name: "VM vanished since the status read", err: loadAPIError(t, "err_vm_missing")},
{name: "pveproxy 596", err: fakeErr(596, "Broken pipe")},
{name: "other 500", err: fakeErr(500, "fake: guest-network-get-interfaces timed out")},
{name: "not an api error", err: errors.New("read tcp: connection reset by peer")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running")
fx.fake.failWith("AgentNetworkInterfaces", tc.err)
assertGetFails(t, fx, provider.ErrTransient)
})
}
}
func TestGet_ipFilterSkipsLoopbackAndLinkLocal(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running").ifaces = []agentInterface{
{Name: "lo", IPAddresses: []agentIP{{Address: "127.0.0.1", Type: "ipv4"}}},
{Name: "ens18", IPAddresses: []agentIP{{Address: "169.254.10.2", Type: "ipv4"}, {Address: "fe80::be24:11ff:fe48:1146", Type: "ipv6"}}},
{Name: "ens19", IPAddresses: []agentIP{{Address: "2001:db8::5", Type: "ipv6"}, {Address: "192.168.4.147", Type: "ipv4"}}},
}
if inst := fx.get(fx.id()); inst.State != provider.StateRunning || inst.IP != "192.168.4.147" {
t.Errorf("Get = %+v, want Running with 192.168.4.147", inst)
}
}
func TestGet_stoppedIsStopped(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "stopped")
if inst := fx.get(fx.id()); inst.State != provider.StateStopped || inst.IP != "" || inst.UID != testUID {
t.Errorf("Get = %+v, want Stopped with uid %s", inst, testUID)
}
if n := fx.fake.callCount("AgentNetworkInterfaces"); n != 0 {
t.Errorf("agent calls = %d, want 0 for a stopped VM", n)
}
}
func TestGet_pausedIsStopped(t *testing.T) {
t.Parallel()
for _, qmp := range []string{"paused", "suspended", "io-error", "postmigrate", "internal-error"} {
t.Run(qmp, func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running").qmpstatus = qmp
if inst := fx.get(fx.id()); inst.State != provider.StateStopped {
t.Errorf("Get state with qmpstatus %s = %s, want Stopped", qmp, inst.State)
}
if n := fx.fake.callCount("AgentNetworkInterfaces"); n != 0 {
t.Errorf("agent calls = %d, want 0", n)
}
})
}
}
// TestGet_pausedIsStoppedFromStatusCurrent uses F14's paused VM: the
// resources listing shows plain running, only status/current tells.
func TestGet_pausedIsStoppedFromStatusCurrent(t *testing.T) {
t.Parallel()
st := decodeData[vmStatus](t, "status_current_paused")
if st.Status != "running" || st.QMPStatus != "paused" {
t.Fatalf("capture status_current_paused = %+v, want running and paused", st)
}
fx := newFixture(t, 1)
vm := fx.addOurVM(testVMID, testName, st.Status)
vm.qmpstatus = st.QMPStatus
vm.ifaces = capturedIfaces(t)
if r, _ := resourceByID(t, fx.fake, testVMID); r.Status != "running" {
t.Fatalf("resources status = %q, want running as F14 saw", r.Status)
}
if inst := fx.get(fx.id()); inst.State != provider.StateStopped || inst.IP != "" {
t.Errorf("Get = %+v, want Stopped with no IP", inst)
}
}
func TestGet_prelaunchIsProvisioning(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
vm := fx.addOurVM(testVMID, testName, "running")
vm.qmpstatus = "prelaunch"
vm.ifaces = capturedIfaces(t)
if inst := fx.get(fx.id()); inst.State != provider.StateProvisioning || inst.IP != "" {
t.Errorf("Get = %+v, want Provisioning", inst)
}
}
func TestGet_suspendedLockIsStopped(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "stopped").lock = lockSuspended
inst := fx.get(fx.id())
if inst.State != provider.StateStopped || inst.UID != testUID {
t.Errorf("Get = %+v, want Stopped with uid", inst)
}
if got := fx.fake.callLog(); !slices.Equal(got, []string{"ClusterResources", "VMConfig"}) {
t.Errorf("calls = %v, want resources and config only", got)
}
}
func TestGet_unknownStatusIsTransient(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running").status = "unknown"
msg := assertGetFails(t, fx, provider.ErrTransient)
if !strings.Contains(msg, "pv1-1") {
t.Errorf("error = %s, want it to name the node", msg)
}
if n := fx.fake.callCount("VMConfig"); n != 0 {
t.Errorf("VMConfig calls = %d, want 0 while the node is unknown", n)
}
}
func TestGet_lockedIsTransientNeverNotFound(t *testing.T) {
t.Parallel()
for _, lock := range []string{"backup", "migrate", "snapshot", "rollback", "create"} {
t.Run("resources lock "+lock, func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running").lock = lock
msg := assertGetFails(t, fx, provider.ErrTransient)
if !strings.Contains(msg, "locked ("+lock+")") {
t.Errorf("error = %s, want it to name the lock", msg)
}
})
}
t.Run("lock taken between the resources and config reads", func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
vm := fx.addOurVM(testVMID, testName, "running")
mutateOn(fx.fake, "VMConfig", func() { vm.lock = "backup" })
assertGetFails(t, fx, provider.ErrTransient)
if n := fx.fake.callCount("VMStatus"); n != 0 {
t.Errorf("VMStatus calls = %d, want 0", n)
}
})
}
// TestGet_midCloneNamelessEntryIsTransient drives a real fake clone: while
// it runs the entry has no name (F8), which must not read as NotFound.
func TestGet_midCloneNamelessEntryIsTransient(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.fake.addTemplate(9001, "debian-13-template")
upid, err := fx.fake.Clone(fx.ctx, fakeNode, 9001, cloneParams{NewID: testVMID, Name: testName, Description: formatDescription(testUID, testCreatedAt)})
if err != nil {
t.Fatalf("Clone error = %v", err)
}
if r, _ := resourceByID(t, fx.fake, testVMID); r.Name != "" || r.Lock != "clone" {
t.Fatalf("mid-clone entry = %+v, want nameless and locked", r)
}
assertGetFails(t, fx, provider.ErrTransient)
pollTask(t, fx.fake, upid)
if inst := fx.get(fx.id()); inst.State != provider.StateStopped || inst.UID != testUID {
t.Errorf("Get after the clone = %+v, want Stopped with uid", inst)
}
}
func TestGet_vanishedBetweenCallsIsNotFound(t *testing.T) {
t.Parallel()
for _, method := range []string{"VMConfig", "VMStatus"} {
t.Run("before "+method, func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running")
mutateOn(fx.fake, method, func() { delete(fx.fake.vms, testVMID) })
assertGetFails(t, fx, provider.ErrNotFound)
})
}
}
func TestGet_unknownStateIsStopped(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
vm := fx.addOurVM(testVMID, testName, "running")
vm.status, vm.qmpstatus = "hibernating-v2", "hibernating-v2"
if inst := fx.get(fx.id()); inst.State != provider.StateStopped {
t.Errorf("Get state = %s, want Stopped for an unrecognised status", inst.State)
}
}
func TestGet_readsUIDAndCreatedAtFromBackend(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
created := time.Date(2026, 9, 1, 8, 30, 0, 0, time.UTC)
fx.addOurVM(testVMID, testName, "stopped").config["description"] = formatDescription("uid-from-backend", created)
inst := fx.get(fx.id())
if inst.UID != "uid-from-backend" || !inst.CreatedAt.Equal(created) {
t.Errorf("Get = uid %q createdAt %v, want uid-from-backend and %v", inst.UID, inst.CreatedAt, created)
}
}
// TestGet_unmanagedDescriptionHasEmptyUID: a mark without a uid line is
// still ours, with an empty UID, which callers must tolerate.
func TestGet_unmanagedDescriptionHasEmptyUID(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "stopped").config["description"] = "proxy-operator-managed=true\r\nadmin note\r\n"
inst := fx.get(fx.id())
if inst.UID != "" || !inst.CreatedAt.IsZero() || inst.State != provider.StateStopped {
t.Errorf("Get = %+v, want empty uid, zero createdAt, Stopped", inst)
}
}
func TestGet_malformedIDIsPermanent(t *testing.T) {
t.Parallel()
for _, id := range []string{"", "vms/150042", "vms/0150042/" + testName, "vm/150042/" + testName, "vms/abc/" + testName, "vms/150042/a/b"} {
t.Run(id, func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
_, err := fx.p.Get(fx.ctx, id)
assertProviderError(t, err, "get", provider.ErrPermanent)
if calls := fx.fake.callLog(); len(calls) != 0 {
t.Errorf("calls = %v, want none", calls)
}
})
}
}
func TestGet_resourcesFailureIsTransient(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
}{
{name: "pveproxy 595", err: fakeErr(595, "Connection refused")},
{name: "other 500", err: fakeErr(500, "fake: cluster filesystem not ready")},
{name: "network", err: errors.New("dial tcp 192.168.0.8:8006: connect: no route to host")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running")
fx.fake.failWith("ClusterResources", tc.err)
assertGetFails(t, fx, provider.ErrTransient)
})
}
}
func TestGet_afterDeleteReturnsNotFound(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running").ifaces = capturedIfaces(t)
if inst := fx.get(fx.id()); inst.State != provider.StateRunning {
t.Fatalf("Get before Delete = %+v, want Running", inst)
}
if err := fx.p.Delete(fx.ctx, fx.id()); err != nil {
t.Fatalf("Delete error = %v", err)
}
assertGetFails(t, fx, provider.ErrNotFound)
}
// TestGet_duringDeleteStillReturnsInstance holds the destroy task running:
// destroying sets no lock and keeps the VM listed until the task ends, as
// Phase 0's mid-destroy captures show, so Get sees it Stopped, not gone.
func TestGet_duringDeleteStillReturnsInstance(t *testing.T) {
t.Parallel()
if _, locked := capturedConfig(t, "config_during_destroy")["lock"]; locked {
t.Fatal("capture config_during_destroy has a lock; the fake models none")
}
listed := decodeData[[]vmResource](t, "cluster_resources_during_destroy")
if !slices.ContainsFunc(listed, func(r vmResource) bool { return r.VMID == 199999 && r.Status == "stopped" && r.Lock == "" }) {
t.Fatal("capture cluster_resources_during_destroy does not list VM 199999 stopped and unlocked")
}
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "running")
inTask, release := make(chan struct{}), make(chan struct{})
var once sync.Once
fx.fake.hookOn("TaskStatus", func() error {
if fx.fake.callCount("Destroy") > 0 {
once.Do(func() {
close(inTask)
<-release
})
}
return nil
})
done := make(chan error, 1)
go func() { done <- fx.p.Delete(fx.ctx, fx.id()) }()
select {
case <-inTask:
case err := <-done:
t.Fatalf("Delete returned %v before its destroy task was polled", err)
}
inst := fx.get(fx.id())
if inst.State != provider.StateStopped || inst.UID != testUID || inst.ID != fx.id() {
t.Errorf("Get during the destroy = %+v, want the instance Stopped with its uid", inst)
}
close(release)
if err := <-done; err != nil {
t.Fatalf("Delete error = %v", err)
}
assertGetFails(t, fx, provider.ErrNotFound)
}