Files
egress-proxies-operator/internal/provider/proxmox/get_test.go
T
kacerrandClaude Opus 5.5 803716459a proxmox: Get, ListByTag and task polling
Get follows spec entry 9's check order (lock and unknown status before
the name, suspended is Stopped after the name and mark checks), reads
status/current so paused is Stopped, and maps the three agent messages
of entry 7. ListByTag reads one config per candidate and fails whole
only on the resources call or an ended ctx. waitTask polls the UPID's
node every budgets.poll, logs failed tasks at V(1) and counts
"WARNINGS: <n>" as success; waitUnlocked serves Create's ownership check.

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

477 lines
17 KiB
Go

package proxmox
import (
"errors"
"slices"
"strings"
"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)
})
}
}