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>
241 lines
8.0 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|