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