Files
egress-proxies-operator/internal/provider/proxmox/task_test.go
T
kacerrandClaude Opus 5.5 34f132d421 proxmox: Create by cloning a template, seed upload, config, start
Create validates placement, then under the per-name lock adopts the VM a
previous attempt left (by name, or at a taken VMID after the ownership
check of spec entry 2) or full-clones the template onto the UID's
deterministic VMID with the mark in the clone call. It then uploads the
seed over any existing file, writes cores, memory, agent, tag, ide2 and
net0 when configured, grows the boot disk when diskGiB is larger, starts,
and waits for running/running.

waitUnlocked no longer waits on a suspended lock: a hibernated VM is
judged at once, so ours is Permanent and a foreign one is probed past.
The template is checked only on the allocation path, and its shape
(net0, boot disk size) is refused before any clone exists. Spec entries
2, 4 and 10 updated to match.

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

236 lines
7.8 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
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()
}
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)
}
})
}
}