Files
egress-proxies-operator/internal/provider/proxmox/task_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

234 lines
7.7 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()
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
}{
{name: "unlocked answers at once", wantCalls: 1},
{
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()
}
cfg, err := fx.p.waitUnlocked(ctx, fakeNode, testVMID, budget)
if tc.wantErr == nil {
if err != nil || cfg["name"] != testName || cfg["lock"] != "" {
t.Errorf("waitUnlocked = %v, %v; want the unlocked config", cfg, err)
}
} 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)
}
})
}
}