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>
234 lines
7.7 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|