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) } }) } }