Files
egress-proxies-operator/internal/provider/proxmox/delete_test.go
T
kacerrandClaude Opus 5.5 6e9ef26dcb proxmox: contract and observability tests, integration test, testing docs
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>
2026-09-23 20:43:00 +00:00

719 lines
24 KiB
Go

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()
within(t, 5*time.Second, func() { 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()
var err error
within(t, 10*time.Second, func() { 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)
}
})
}
}