Files
egress-proxies-operator/internal/provider/proxmox/task_test.go
T
kacerrandClaude Opus 5.5 6e9ef26dcb proxmox: contract and observability tests, integration test, testing docs
The contract sweep in provider_test.go: every method and Check returns
within a second of a cancel, every wrapErr path carries the configured
name, op and id, the V0/V1/V2 logging tiers and the no-op lines, and a
Get over the real client whose HTTP spans are children of provider.get.
proxmox_integration_test.go (-tags=integration) holds TestProxmox_lifecycle
and TestProxmoxCheck_realHost; both compile and skip without
PROXMOX_TEST_*, and have not run against a host yet. make
test-integration-proxmox runs them. docs/testing.md gains the proxmox,
nocloud and keyedlock rows, the Live Proxmox gaps and the integration
section; spec entry 21's section 11 list is ticked.

Waits that could hang the package under a mutation are bounded: the
concurrent-Create loop and eight direct blocking calls in older tests.

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

241 lines
8.0 KiB
Go

package proxmox
import (
"context"
"errors"
"strings"
"testing"
"time"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// addTask registers a fake task on node that ends with exit.
func addTask(f *fakeAPI, node, exit string) string {
f.mu.Lock()
defer f.mu.Unlock()
return f.newTask(node, "qmstart", "150042", exit, nil)
}
func TestParseUPID_table(t *testing.T) {
t.Parallel()
tests := []struct {
name string
upid string
wantNode string
}{
{name: "captured clone", upid: decodeData[string](t, "clone_response"), wantNode: "pv1-1"},
{name: "captured task status", upid: decodeData[taskStatus](t, "task_status_stopped_ok").UPID, wantNode: "pv1-1"},
{name: "captured upload", upid: decodeData[string](t, "upload_response_proxy_seeds"), wantNode: "pv1-1"},
{name: "fake task on another node", upid: addTask(newFakeAPI(), "pv1-2", "OK"), wantNode: "pv1-2"},
{name: "empty", upid: ""},
{name: "prefix only", upid: "UPID:"},
{name: "lower-case prefix", upid: "upid:pv1-1:0010F9DC:02801DA1:6AB37871:qmclone:9001:root@pam:"},
{name: "no trailing colon", upid: "UPID:pv1-1:0010F9DC:02801DA1:6AB37871:qmclone:9001:root@pam"},
{name: "slash in node", upid: "UPID:pv1/1:0010F9DC:02801DA1:6AB37871:qmclone:9001:root@pam:"},
{name: "pid not hex", upid: "UPID:pv1-1:0010F9DZ:02801DA1:6AB37871:qmclone:9001:root@pam:"},
{name: "missing user", upid: "UPID:pv1-1:0010F9DC:02801DA1:6AB37871:qmclone:9001::"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
node, err := parseUPID(tc.upid)
if tc.wantNode == "" {
if !errors.Is(err, provider.ErrPermanent) {
t.Errorf("parseUPID(%q) = %q, %v; want ErrPermanent", tc.upid, node, err)
}
return
}
if err != nil || node != tc.wantNode {
t.Errorf("parseUPID(%q) = %q, %v; want %q", tc.upid, node, err, tc.wantNode)
}
})
}
}
func TestWaitTask_table(t *testing.T) {
t.Parallel()
failedExit := loadTaskExit(t, "task_status_stopped_error")
tests := []struct {
name string
node string
exit string
upid string // overrides the registered task's UPID
polls int
budget time.Duration
cancelled bool
statusErr error
wantClass error // nil for success
wantCalls int
wantInErr string
wantLog string
}{
{name: "ok on the first poll", exit: "OK", wantCalls: 1},
{name: "polls until stopped", exit: "OK", polls: 3, wantCalls: 4},
{name: "polls the node the UPID names", node: "pv1-2", exit: "OK", wantCalls: 1},
{name: "warnings count as success", exit: "WARNINGS: 2", wantCalls: 1, wantLog: `"msg"="proxmox task finished with warnings"`},
{
name: "failed exitstatus is transient and logged", exit: failedExit, wantClass: provider.ErrTransient, wantCalls: 1,
wantInErr: failedExit, wantLog: `"msg"="proxmox task failed"`,
},
{name: "budget exceeded names the UPID", exit: "OK", polls: 1 << 30, budget: 20 * time.Millisecond, wantClass: provider.ErrTransient, wantCalls: -1, wantInErr: "UPID:pv1-1:"},
{name: "ended ctx is transient", exit: "OK", polls: 1 << 30, cancelled: true, wantClass: provider.ErrTransient, wantCalls: 1, wantInErr: context.Canceled.Error()},
{name: "malformed UPID makes no call", upid: "UPID:bogus", wantClass: provider.ErrPermanent, wantCalls: 0},
{name: "status read failing is returned", exit: "OK", statusErr: loadAPIError(t, "err_forbidden"), wantClass: provider.ErrPermanent, wantCalls: 1, wantInErr: "Permission check failed"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.fake.taskPolls = tc.polls
if tc.statusErr != nil {
fx.fake.failWith("TaskStatus", tc.statusErr)
}
node := tc.node
if node == "" {
node = fakeNode
}
upid := addTask(fx.fake, node, tc.exit)
if tc.upid != "" {
upid = tc.upid
}
budget := tc.budget
if budget == 0 {
budget = time.Minute
}
ctx := fx.ctx
if tc.cancelled {
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
cancel()
}
start := time.Now()
var err error
within(t, 10*time.Second, func() { err = fx.p.waitTask(ctx, fx.p.logger(ctx), upid, budget) })
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Errorf("waitTask took %v", elapsed)
}
if tc.wantClass == nil {
if err != nil {
t.Fatalf("waitTask error = %v, want nil", err)
}
} else if classify("create", err) != tc.wantClass {
t.Fatalf("waitTask error = %v, want class %v", err, tc.wantClass)
}
if tc.wantInErr != "" && !strings.Contains(err.Error(), tc.wantInErr) {
t.Errorf("waitTask error = %q, want it to contain %q", err, tc.wantInErr)
}
if got := fx.fake.callCount("TaskStatus"); tc.wantCalls >= 0 && got != tc.wantCalls {
t.Errorf("TaskStatus calls = %d, want %d", got, tc.wantCalls)
}
if tc.wantLog != "" {
lines := fx.logs.matching(tc.wantLog)
if len(lines) != 1 || !strings.Contains(lines[0], `"upid"="`+upid+`"`) || !strings.Contains(lines[0], `"exitstatus"="`+tc.exit+`"`) {
t.Errorf("log lines %q = %v, want one with upid and exitstatus", tc.wantLog, lines)
}
}
})
}
}
func TestWaitTask_quietAtV0(t *testing.T) {
t.Parallel()
fx := newFixture(t, 0)
err := fx.p.waitTask(fx.ctx, fx.p.logger(fx.ctx), addTask(fx.fake, fakeNode, "boom"), time.Minute)
if err == nil {
t.Fatal("waitTask on a failed task returned nil")
}
if lines := fx.logs.all(); len(lines) != 0 {
t.Errorf("V(0) lines = %v, want none", lines)
}
}
func TestWaitUnlocked_table(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lock string
// onCall runs on the n-th VMConfig call (1-based) under f.mu.
onCall func(f *fakeAPI, n int)
budget time.Duration
cancelled bool
wantErr func(error) bool
wantCalls int
wantLock string
}{
{name: "unlocked answers at once", wantCalls: 1},
{name: "suspended lock is not waited on", lock: lockSuspended, budget: time.Hour, wantCalls: 1, wantLock: lockSuspended},
{
name: "waits until the lock clears", lock: "clone", wantCalls: 3,
onCall: func(f *fakeAPI, n int) {
if n == 3 {
f.vms[testVMID].lock = ""
}
},
},
{
name: "lock that never clears is transient", lock: "clone", budget: 20 * time.Millisecond, wantCalls: -1,
wantErr: func(err error) bool {
return errors.Is(err, provider.ErrTransient) && strings.Contains(err.Error(), "the lock on VM 150042")
},
},
{
name: "VM removed by a failed clone gives does not exist", lock: "clone", wantCalls: 2,
onCall: func(f *fakeAPI, n int) {
if n == 2 {
delete(f.vms, testVMID)
}
},
wantErr: isNotExist,
},
{
name: "ended ctx is transient", lock: "clone", cancelled: true, wantCalls: 1,
wantErr: func(err error) bool {
return errors.Is(err, provider.ErrTransient) && errors.Is(err, context.Canceled)
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.addOurVM(testVMID, testName, "stopped").lock = tc.lock
var n int
fx.fake.hookOn("VMConfig", func() error {
fx.fake.mu.Lock()
defer fx.fake.mu.Unlock()
n++
if tc.onCall != nil {
tc.onCall(fx.fake, n)
}
return nil
})
budget := tc.budget
if budget == 0 {
budget = time.Minute
}
ctx := fx.ctx
if tc.cancelled {
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
cancel()
}
var (
cfg vmConfig
err error
)
within(t, 10*time.Second, func() { cfg, err = fx.p.waitUnlocked(ctx, fakeNode, testVMID, budget) })
if tc.wantErr == nil {
if err != nil || cfg["name"] != testName || cfg["lock"] != tc.wantLock {
t.Errorf("waitUnlocked = %v, %v; want the config with lock %q", cfg, err, tc.wantLock)
}
} else if !tc.wantErr(err) || cfg != nil {
t.Errorf("waitUnlocked = %v, %v; want no config and the expected error", cfg, err)
}
if got := fx.fake.callCount("VMConfig"); tc.wantCalls >= 0 && got != tc.wantCalls {
t.Errorf("VMConfig calls = %d, want %d", got, tc.wantCalls)
}
})
}
}