package proxmox import ( "context" "errors" "net/http" "slices" "strconv" "strings" "sync" "testing" "time" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" ) // putSeed uploads testName's seed to the fake's ISO storage, as Create // leaves it. func (fx *fixture) putSeed() { fx.t.Helper() upid, err := fx.fake.UploadISO(context.Background(), fakeNode, fakeISOStorage, seedISOName(testName), strings.NewReader("seed"), 4) if err != nil { fx.t.Fatalf("UploadISO error = %v", err) } pollTask(fx.t, fx.fake, upid) } func (fx *fixture) seedPresent() bool { _, ok := fx.fake.iso(fakeISOStorage, seedISOName(testName)) return ok } // deleteFails runs Delete on fx.id(), checks the error's class and returns // its text. func (fx *fixture) deleteFails(class error) string { fx.t.Helper() err := fx.p.Delete(fx.ctx, fx.id()) assertProviderError(fx.t, err, "delete", class) return err.Error() } func (fx *fixture) delete() { fx.t.Helper() if err := fx.p.Delete(fx.ctx, fx.id()); err != nil { fx.t.Fatalf("Delete error = %v", err) } } // seedDeleteNodes returns the node of every DeleteVolume call, read from // the decorator's lines; the fixture's api must be observed. func (fx *fixture) seedDeleteNodes() []string { lines := fx.logs.matching(`"call"="DeleteVolume"`) nodes := make([]string, 0, len(lines)) for _, l := range lines { _, rest, _ := strings.Cut(l, `"node"="`) node, _, _ := strings.Cut(rest, `"`) nodes = append(nodes, node) } return nodes } // twoNodeFixture is an observed fixture at V(1) with nodes pv1-1 and pv1-2. func twoNodeFixture(t *testing.T) *fixture { t.Helper() fx := newFixture(t, 1) fx.p.api = observe(fx.fake, testProvider) fx.p.cfg.Nodes = []string{fakeNode, "pv1-2"} return fx } func TestDelete_malformedIDIsPermanent(t *testing.T) { t.Parallel() for _, id := range []string{"", "vms/150042", "vms/0150042/" + testName, "vm/150042/" + testName, "vms/abc/" + testName, "vms/150042/a/b", "vms/99/" + testName} { t.Run(id, func(t *testing.T) { t.Parallel() fx := newFixture(t, 1) assertProviderError(t, fx.p.Delete(fx.ctx, id), "delete", provider.ErrPermanent) if calls := fx.fake.callLog(); len(calls) != 0 { t.Errorf("calls = %v, want none", calls) } }) } } func TestDelete_twiceSucceeds(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.addOurVM(testVMID, testName, "running") fx.putSeed() fx.delete() fx.delete() if _, ok := fx.fake.vmSnapshot(testVMID); ok { t.Error("VM still exists") } if fx.seedPresent() { t.Error("seed still exists") } for method, want := range map[string]int{"Stop": 1, "Destroy": 1, "DeleteVolume": 2} { if got := fx.fake.callCount(method); got != want { t.Errorf("%s calls = %d, want %d", method, got, want) } } } // TestDelete_absentStillDeletesSeedOnEveryNode replaces the plan's // TestDelete_absentIsNoOp: spec entry 11 deletes the seed even when the VM // is gone, on every node, because the VM's node is no longer known. func TestDelete_absentStillDeletesSeedOnEveryNode(t *testing.T) { t.Parallel() fx := twoNodeFixture(t) fx.putSeed() fx.delete() if got := fx.seedDeleteNodes(); !slices.Equal(got, []string{fakeNode, "pv1-2"}) { t.Errorf("seed deleted on %v, want every node in config order", got) } if fx.seedPresent() { t.Error("seed still exists") } for _, method := range []string{"VMConfig", "Stop", "Destroy"} { if n := fx.fake.callCount(method); n != 0 { t.Errorf("%s calls = %d, want 0 for an absent VM", method, n) } } if lines := fx.logs.matching(`"msg"="proxmox vm already gone"`); len(lines) != 1 { t.Errorf("already-gone lines = %v, want one", lines) } } func TestDelete_containerAtVMIDIsLeftAlone(t *testing.T) { t.Parallel() fx := twoNodeFixture(t) fx.addOurVM(testVMID, testName, "running").kind = "lxc" fx.delete() if vm, ok := fx.fake.vmSnapshot(testVMID); !ok || vm.status != "running" { t.Errorf("container = %+v, %v; want it untouched", vm, ok) } if got := fx.seedDeleteNodes(); len(got) != 2 { t.Errorf("seed deleted on %v, want every node", got) } } // TestDelete_foreignNameLeavesVMAlone replaces the plan's // TestDelete_foreignNameIsNoOp: the VM is not touched, but our seed is // still deleted, on every node since ours is gone. func TestDelete_foreignNameLeavesVMAlone(t *testing.T) { t.Parallel() fx := twoNodeFixture(t) fx.addOurVM(testVMID, "proxy-someoneelse00000", "running") fx.putSeed() fx.delete() if vm, ok := fx.fake.vmSnapshot(testVMID); !ok || vm.status != "running" { t.Errorf("foreign VM = %+v, %v; want it running and untouched", vm, ok) } for _, method := range []string{"Stop", "Destroy", "SetVMConfig"} { if n := fx.fake.callCount(method); n != 0 { t.Errorf("%s calls = %d, want 0", method, n) } } if got := fx.seedDeleteNodes(); !slices.Equal(got, []string{fakeNode, "pv1-2"}) { t.Errorf("seed deleted on %v, want every node", got) } lines := fx.logs.matching(`"msg"="proxmox vm at this vmid is not ours, left alone"`) if len(lines) != 1 || !strings.Contains(lines[0], `"vmName"="proxy-someoneelse00000"`) { t.Errorf("no-op lines = %v, want one naming the other VM", lines) } } // TestDelete_unmarkedLeavesVMAlone replaces the plan's // TestDelete_unmarkedIsNoOp, for the same reason. func TestDelete_unmarkedLeavesVMAlone(t *testing.T) { t.Parallel() for name, desc := range map[string]string{ "no description": "", "managed false": "proxy-operator-managed=false\nproxy-operator-uid=" + testUID, } { t.Run(name, func(t *testing.T) { t.Parallel() fx := twoNodeFixture(t) vm := fx.fake.addVM(testVMID, testName, "stopped") if desc != "" { vm.config["description"] = desc } fx.delete() if _, ok := fx.fake.vmSnapshot(testVMID); !ok { t.Error("unmarked VM was destroyed") } if n := fx.fake.callCount("Destroy"); n != 0 { t.Errorf("Destroy calls = %d, want 0", n) } if got := fx.seedDeleteNodes(); len(got) != 2 { t.Errorf("seed deleted on %v, want every node", got) } if lines := fx.logs.matching(`"msg"="proxmox vm carries no proxy-operator mark, left alone"`); len(lines) != 1 { t.Errorf("no-op lines = %v, want one", lines) } }) } } // TestDelete_runningVMStoppedFirst covers a paused VM too: the listing // shows it as running (F14), and Stop ends it. The fake refuses to destroy // a running VM, and each task takes two polls, so a Destroy that does not // wait for the stop fails. func TestDelete_runningVMStoppedFirst(t *testing.T) { t.Parallel() for _, qmp := range []string{"running", "paused", "prelaunch"} { t.Run(qmp, func(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.fake.taskPolls = 2 fx.addOurVM(testVMID, testName, "running").qmpstatus = qmp fx.delete() log := fx.fake.callLog() stop, destroy := slices.Index(log, "Stop"), slices.Index(log, "Destroy") if stop < 0 || destroy < stop { t.Fatalf("calls = %v, want Stop before Destroy", log) } if n := callsBefore(log[stop:], "TaskStatus", "Destroy"); n != fx.fake.taskPolls+1 { t.Errorf("stop task polled %d times before Destroy, want %d", n, fx.fake.taskPolls+1) } if _, ok := fx.fake.vmSnapshot(testVMID); ok { t.Error("VM still exists") } }) } } func TestDelete_stoppedVMSkipsStop(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.addOurVM(testVMID, testName, "stopped") fx.delete() want := []string{"ClusterResources", "VMConfig", "Destroy", "TaskStatus", "DeleteVolume", "TaskStatus"} if got := fx.fake.callLog(); !slices.Equal(got, want) { t.Errorf("calls = %v, want %v", got, want) } } func TestDelete_lockedIsTransient(t *testing.T) { t.Parallel() for _, lock := range []string{"backup", "migrate", "snapshot", "rollback", "create"} { t.Run("resources lock "+lock, func(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.addOurVM(testVMID, testName, "running").lock = lock fx.putSeed() if msg := fx.deleteFails(provider.ErrTransient); !strings.Contains(msg, "locked ("+lock+")") { t.Errorf("error = %s, want it to name the lock", msg) } if got := fx.fake.callLog(); !slices.Equal(got, []string{"UploadISO", "TaskStatus", "ClusterResources"}) { t.Errorf("calls = %v, want only the listing after the seed setup", got) } if !fx.seedPresent() { t.Error("seed deleted while the VM is locked") } }) } t.Run("mid-clone nameless entry", func(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.fake.addTemplate(9001, "debian-13-template") if _, err := fx.fake.Clone(fx.ctx, fakeNode, 9001, cloneParams{NewID: testVMID, Name: testName, Description: formatDescription(testUID, testCreatedAt)}); err != nil { t.Fatalf("Clone error = %v", err) } fx.deleteFails(provider.ErrTransient) if _, ok := fx.fake.vmSnapshot(testVMID); !ok { t.Error("mid-clone VM is gone") } }) t.Run("lock taken between the resources and config reads", func(t *testing.T) { t.Parallel() fx := newFixture(t, 1) vm := fx.addOurVM(testVMID, testName, "running") mutateOn(fx.fake, "VMConfig", func() { vm.lock = "backup" }) fx.deleteFails(provider.ErrTransient) if n := fx.fake.callCount("Stop"); n != 0 { t.Errorf("Stop calls = %d, want 0", n) } }) } func TestDelete_unknownStatusIsTransient(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.addOurVM(testVMID, testName, "running").status = "unknown" if msg := fx.deleteFails(provider.ErrTransient); !strings.Contains(msg, fakeNode) { t.Errorf("error = %s, want it to name the node", msg) } if n := fx.fake.callCount("VMConfig"); n != 0 { t.Errorf("VMConfig calls = %d, want 0 while the node is unknown", n) } } // TestDelete_hibernatedVMIsPermanentIfOurs matches Create's ownership // check: Destroy refuses the suspended lock, which never clears on its own, // so an admin must act. A hibernated VM that is not ours is left alone. func TestDelete_hibernatedVMIsPermanentIfOurs(t *testing.T) { t.Parallel() t.Run("ours", func(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.addOurVM(testVMID, testName, "stopped").lock = lockSuspended fx.putSeed() if msg := fx.deleteFails(provider.ErrPermanent); !strings.Contains(msg, "resume or destroy it in Proxmox") { t.Errorf("error = %s, want the admin action", msg) } for _, method := range []string{"Stop", "Destroy", "DeleteVolume"} { if n := fx.fake.callCount(method); n != 0 { t.Errorf("%s calls = %d, want 0", method, n) } } if _, err := fx.fake.Destroy(fx.ctx, fakeNode, testVMID); !isLocked(err) { t.Errorf("fake Destroy on the hibernated VM error = %v, want the lock answer", err) } }) t.Run("someone else's", func(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.addOurVM(testVMID, "proxy-someoneelse00000", "stopped").lock = lockSuspended fx.delete() if vm, ok := fx.fake.vmSnapshot(testVMID); !ok || vm.lock != lockSuspended { t.Errorf("foreign hibernated VM = %+v, %v; want it untouched", vm, ok) } }) } func TestDelete_removesSeedISO(t *testing.T) { t.Parallel() fx := twoNodeFixture(t) fx.addOurVM(testVMID, testName, "stopped").node = "pv1-2" fx.putSeed() fx.delete() if fx.seedPresent() { t.Error("seed still exists") } if got := fx.seedDeleteNodes(); !slices.Equal(got, []string{"pv1-2"}) { t.Errorf("seed deleted on %v, want only the VM's node pv1-2", got) } lines := fx.logs.matching(`"call"="DeleteVolume"`) if len(lines) != 1 || !strings.Contains(lines[0], `"volid"="`+seedVolID(fakeISOStorage, testName)+`"`) || !strings.Contains(lines[0], `"storage"="`+fakeISOStorage+`"`) { t.Errorf("DeleteVolume lines = %v, want the seed's volid on the ISO storage", lines) } } // TestDelete_seedDeleteIsTaskAndIdempotent replaces the plan's // TestDelete_toleratesMissingISO: Proxmox ends a delete of a missing volume // with a successful imgdel task (F13), so nothing is matched, but the task // is still waited on. func TestDelete_seedDeleteIsTaskAndIdempotent(t *testing.T) { t.Parallel() missing := decodeData[taskStatus](t, "task_status_volume_missing") if missing.Type != "imgdel" || missing.ExitStatus != "OK" { t.Fatalf("capture task_status_volume_missing = %+v, want an imgdel task ending OK", missing) } for _, withSeed := range []bool{true, false} { t.Run("seed present "+strconv.FormatBool(withSeed), func(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.addOurVM(testVMID, testName, "stopped") if withSeed { fx.putSeed() } fx.fake.taskPolls = 2 fx.delete() log := fx.fake.callLog() last := len(log) - 1 for log[last] != "DeleteVolume" { last-- } if n := callsBefore(log[last:], "TaskStatus", ""); n != fx.fake.taskPolls+1 { t.Errorf("seed delete task polled %d times, want %d: %v", n, fx.fake.taskPolls+1, log) } if fx.seedPresent() { t.Error("seed still exists") } }) } } func TestDelete_seedDeleteFailureOnVMNodeFails(t *testing.T) { t.Parallel() tests := []struct { name string setup func(fx *fixture) class error }{ {name: "token lacks Datastore.Allocate", setup: func(fx *fixture) { fx.fake.failWith("DeleteVolume", loadAPIError(t, "err_forbidden_volume_delete")) }, class: provider.ErrPermanent}, {name: "pveproxy 596", setup: func(fx *fixture) { fx.fake.failWith("DeleteVolume", fakeErr(596, "Broken pipe")) }, class: provider.ErrTransient}, {name: "task failed", setup: func(fx *fixture) { fx.fake.hookOn("TaskStatus", func() error { if fx.fake.callCount("DeleteVolume") > 0 { return fakeErr(500, "fake: task log unreadable") } return nil }) }, class: provider.ErrTransient}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.addOurVM(testVMID, testName, "stopped") tc.setup(fx) fx.deleteFails(tc.class) if _, ok := fx.fake.vmSnapshot(testVMID); ok { t.Error("VM still exists: the destroy should have run before the seed delete") } }) } } func TestDelete_seedDeleteNodeFailureSkippedWhenNodeUnknown(t *testing.T) { t.Parallel() t.Run("one node refuses", func(t *testing.T) { t.Parallel() fx := twoNodeFixture(t) fx.putSeed() var calls int fx.fake.hookOn("DeleteVolume", func() error { if calls++; calls == 1 { return fakeErr(500, "fake: storage 'proxy-seeds' is not available on node 'pv1-1'") } return nil }) fx.delete() if got := fx.seedDeleteNodes(); !slices.Equal(got, []string{fakeNode, "pv1-2"}) { t.Errorf("seed deleted on %v, want both nodes tried", got) } lines := fx.logs.matching(`"msg"="proxmox seed delete failed on a node, skipped"`) if len(lines) != 1 || !strings.Contains(lines[0], `"node"="pv1-1"`) || !strings.Contains(lines[0], "not available") { t.Errorf("skip lines = %v, want one naming pv1-1 and the reason", lines) } if fx.seedPresent() { t.Error("seed still exists after pv1-2's delete") } }) t.Run("ended ctx fails the call", func(t *testing.T) { t.Parallel() fx := twoNodeFixture(t) ctx, cancel := context.WithCancel(fx.ctx) defer cancel() fx.fake.hookOn("DeleteVolume", func() error { cancel() return context.Canceled }) err := fx.p.Delete(ctx, fx.id()) assertProviderError(t, err, "delete", provider.ErrTransient) if n := fx.fake.callCount("DeleteVolume"); n != 1 { t.Errorf("DeleteVolume calls = %d, want 1: the ended ctx stops the loop", n) } }) } func TestDelete_destroyTaskFailureIsTransient(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.addOurVM(testVMID, testName, "stopped") fx.putSeed() fx.fake.destroyFailsWith = "fake: unable to remove disk" if msg := fx.deleteFails(provider.ErrTransient); !strings.Contains(msg, "unable to remove disk") { t.Errorf("error = %s, want the task's exitstatus", msg) } if _, ok := fx.fake.vmSnapshot(testVMID); !ok { t.Error("VM gone after a failed destroy task") } if !fx.seedPresent() { t.Error("seed deleted although the VM is still there") } if lines := fx.logs.matching(`"msg"="proxmox task failed"`); len(lines) != 1 { t.Errorf("task failed lines = %v, want one", lines) } } // TestDelete_goneMidwayIsSuccess: the VM vanishing at any step counts as // done, and F2's does-not-exist answer is what each step then sees. func TestDelete_goneMidwayIsSuccess(t *testing.T) { t.Parallel() tests := []struct { name string status string method string wantDestroy int wantNodes []string }{ {name: "before the config read", status: "stopped", method: "VMConfig", wantNodes: []string{fakeNode, "pv1-2"}}, {name: "before the stop", status: "running", method: "Stop", wantNodes: []string{fakeNode}}, {name: "before the destroy", status: "stopped", method: "Destroy", wantDestroy: 1, wantNodes: []string{fakeNode}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() fx := twoNodeFixture(t) fx.addOurVM(testVMID, testName, tc.status) mutateOn(fx.fake, tc.method, func() { delete(fx.fake.vms, testVMID) }) fx.delete() if n := fx.fake.callCount("Destroy"); n != tc.wantDestroy { t.Errorf("Destroy calls = %d, want %d", n, tc.wantDestroy) } if got := fx.seedDeleteNodes(); !slices.Equal(got, tc.wantNodes) { t.Errorf("seed deleted on %v, want %v", got, tc.wantNodes) } }) } } func TestDelete_resourcesFailureIsTransient(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.addOurVM(testVMID, testName, "running") fx.fake.failWith("ClusterResources", fakeErr(595, "Connection refused")) fx.deleteFails(provider.ErrTransient) if n := fx.fake.callCount("DeleteVolume"); n != 0 { t.Errorf("DeleteVolume calls = %d, want 0 when the VM's state is unknown", n) } } // TestDelete_usesPurgeAndDestroyUnreferencedDisks runs Delete over the real // client against Phase 0's destroy captures: the DELETE carries both flags // and the seed delete sends the volid as one escaped segment. func TestDelete_usesPurgeAndDestroyUnreferencedDisks(t *testing.T) { t.Parallel() const ( vmid = 199999 name = "proxy-reconaaaaaaaaaaa" ) var ( mu sync.Mutex seen []string ) cfx := newClientFixture(t, func(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.EscapedPath(), "/api2/json/") mu.Lock() seen = append(seen, r.Method+" "+path+"?"+r.URL.RawQuery) mu.Unlock() capture := "" switch { case r.Method == http.MethodGet && path == "cluster/resources": capture = "cluster_resources_during_destroy" case r.Method == http.MethodGet && path == "nodes/pv1-1/qemu/199999/config": capture = "config_during_destroy" case r.Method == http.MethodDelete && path == "nodes/pv1-1/qemu/199999": capture = "destroy_response" case r.Method == http.MethodDelete && path == "nodes/pv1-1/storage/proxy-seeds/content/proxy-seeds:iso%2F"+name+"-seed.iso": capture = "content_delete_response" case r.Method == http.MethodGet && strings.Contains(path, ":imgdel:"): capture = "task_status_volume_missing" case r.Method == http.MethodGet && strings.Contains(path, ":qmdestroy:"): capture = "task_status_stopped_ok" default: t.Errorf("unexpected request %s %s", r.Method, path) http.Error(w, "unexpected", http.StatusNotImplemented) return } serveJSON(w, readCapture(t, capture).Body) }) cfg := testConfig() cfg.URL, cfg.TokenFile, cfg.CAFile = cfx.cfg.URL, cfx.cfg.TokenFile, cfx.cfg.CAFile p := newProvider(testProvider, cfg, cfx.api(t)) p.budgets = testBudgets() if err := p.Delete(context.Background(), formatProviderID(vmid, name)); err != nil { t.Fatalf("Delete error = %v", err) } mu.Lock() defer mu.Unlock() if !slices.Contains(seen, "DELETE nodes/pv1-1/qemu/199999?destroy-unreferenced-disks=1&purge=1") { t.Errorf("requests = %v, want the destroy with purge=1 and destroy-unreferenced-disks=1", seen) } if !slices.ContainsFunc(seen, func(s string) bool { return strings.HasPrefix(s, "DELETE nodes/pv1-1/storage/proxy-seeds/content/") }) { t.Errorf("requests = %v, want the seed delete", seen) } if slices.ContainsFunc(seen, func(s string) bool { return strings.Contains(s, "status/stop") }) { t.Errorf("requests = %v, want no stop for the captured stopped VM", seen) } } func TestDelete_concurrentSameNameSafe(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.addOurVM(testVMID, testName, "running") release := fx.fake.blockOn("Stop") errs := make([]error, 2) first := make(chan struct{}) var wg sync.WaitGroup wg.Go(func() { defer close(first) errs[0] = fx.p.Delete(fx.ctx, fx.id()) }) for fx.fake.callCount("Stop") == 0 { select { case <-first: t.Fatalf("first Delete returned %v without stopping the VM", errs[0]) case <-time.After(time.Millisecond): } } wg.Go(func() { errs[1] = fx.p.Delete(fx.ctx, fx.id()) }) close(release) wg.Wait() for i, err := range errs { if err != nil { t.Errorf("Delete %d error = %v", i, err) } } for method, want := range map[string]int{"Stop": 1, "Destroy": 1} { if got := fx.fake.callCount(method); got != want { t.Errorf("%s calls = %d, want %d", method, got, want) } } } // TestDelete_waitsOnTheNameLock: Delete takes the per-name lock before any // call, so it cannot interleave with a Create or another Delete of that // name (spec entry 19). func TestDelete_waitsOnTheNameLock(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.addOurVM(testVMID, testName, "running") release, err := fx.p.lockName(fx.ctx, testName) if err != nil { t.Fatalf("lockName error = %v", err) } defer release() ctx, cancel := context.WithTimeout(fx.ctx, 20*time.Millisecond) defer cancel() err = fx.p.Delete(ctx, fx.id()) assertProviderError(t, err, "delete", provider.ErrTransient) if !errors.Is(err, context.DeadlineExceeded) { t.Errorf("error = %v, want it to wrap DeadlineExceeded", err) } if calls := fx.fake.callLog(); len(calls) != 0 { t.Errorf("calls = %v, want none while the name is locked", calls) } } func TestDelete_cancelledMidWaitReturnsPromptly(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.p.budgets = defaultBudgets() fx.p.budgets.poll = time.Millisecond fx.fake.taskPolls = 1 << 30 fx.addOurVM(testVMID, testName, "running") ctx, cancel := context.WithCancel(fx.ctx) defer cancel() fx.fake.hookOn("TaskStatus", func() error { cancel() return nil }) start := time.Now() err := fx.p.Delete(ctx, fx.id()) if elapsed := time.Since(start); elapsed > 5*time.Second { t.Errorf("Delete took %v after cancellation", elapsed) } assertProviderError(t, err, "delete", provider.ErrTransient) if !errors.Is(err, context.Canceled) { t.Errorf("error = %v, want it to wrap context.Canceled", err) } } func TestDelete_outcomeLine(t *testing.T) { t.Parallel() tests := []struct { name string setup func(fx *fixture) want []string never string }{ { name: "destroyed", setup: func(fx *fixture) { fx.addOurVM(testVMID, testName, "stopped") }, want: []string{`"node"="` + fakeNode + `"`, `"destroyed"=true`}, }, {name: "already gone", setup: func(*fixture) {}, want: []string{`"destroyed"=false`}, never: `"node"=`}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() fx := newFixture(t, 1) tc.setup(fx) fx.delete() lines := fx.logs.matching(`"msg"="proxmox vm deleted"`) if len(lines) != 1 { t.Fatalf("outcome lines = %d, want 1:\n%s", len(lines), strings.Join(fx.logs.all(), "\n")) } for _, want := range append(tc.want, `"vmid"=150042`, `"name"="`+testName+`"`, `"provider"="`+testProvider+`"`) { if !strings.Contains(lines[0], want) { t.Errorf("outcome line lacks %s: %s", want, lines[0]) } } if tc.never != "" && strings.Contains(lines[0], tc.never) { t.Errorf("outcome line has %s: %s", tc.never, lines[0]) } }) } } func TestDelete_noOpLinesAreSilentAtV0(t *testing.T) { t.Parallel() tests := map[string]func(fx *fixture){ "absent": func(*fixture) {}, "foreign": func(fx *fixture) { fx.addOurVM(testVMID, "proxy-someoneelse00000", "running") }, "unmarked": func(fx *fixture) { fx.fake.addVM(testVMID, testName, "running") }, "ours": func(fx *fixture) { fx.addOurVM(testVMID, testName, "running") }, } for name, setup := range tests { t.Run(name, func(t *testing.T) { t.Parallel() fx := newFixture(t, 0) fx.p.api = observe(fx.fake, testProvider) setup(fx) fx.delete() if lines := fx.logs.all(); len(lines) != 0 { t.Errorf("V(0) lines = %v, want none", lines) } }) } }