package proxmox import ( "bytes" "context" "encoding/json" "errors" "slices" "strconv" "strings" "sync" "testing" "time" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/nocloud" ) const testTemplateVMID = 9001 // slot is the VMID Create tries for testUID at attempt n under the default // range. func slot(n int) int { return vmidFor(testUID, defaultVMIDMin, defaultVMIDMax, n) } // newCreateFixture is a fixture whose cluster holds template 9001, the one // testConfig's "debian-13" names. func newCreateFixture(t *testing.T, verbosity int) *fixture { t.Helper() fx := newFixture(t, verbosity) fx.fake.addTemplate(testTemplateVMID, "debian-13-template") return fx } func (fx *fixture) req() provider.CreateRequest { return provider.CreateRequest{ Name: testName, UID: testUID, Placement: provider.Placement{MachineType: "small", Image: "debian-13"}, CloudInit: "#cloud-config\npackages: [squid]\n", } } func (fx *fixture) create(req provider.CreateRequest) string { fx.t.Helper() id, err := fx.p.Create(fx.ctx, req) if err != nil { fx.t.Fatalf("Create error = %v", err) } return id } // createFails runs Create, checks the error's class and returns its text. func (fx *fixture) createFails(req provider.CreateRequest, class error) string { fx.t.Helper() id, err := fx.p.Create(fx.ctx, req) if id != "" { fx.t.Errorf("Create id = %q, want empty on error", id) } assertProviderError(fx.t, err, "create", class) return err.Error() } func (fx *fixture) assertRunning(vmid int) fakeVM { fx.t.Helper() vm, ok := fx.fake.vmSnapshot(vmid) if !ok { fx.t.Fatalf("VM %d does not exist", vmid) } if vm.status != "running" || vm.qmpstatus != "running" { fx.t.Errorf("VM %d status = %s/%s, want running/running", vmid, vm.status, vm.qmpstatus) } return vm } // callsBefore counts method in the call log before the first call to // stop. func callsBefore(log []string, method, stop string) int { end := slices.Index(log, stop) if end < 0 { end = len(log) } n := 0 for _, c := range log[:end] { if c == method { n++ } } return n } // clearLockOnVMConfig clears vmid's lock at the start of the n-th VMConfig // call, so Create has to poll the locked VM before it sees it free. func clearLockOnVMConfig(f *fakeAPI, vmid, n int) { var calls int mutateOn(f, "VMConfig", func() { if calls++; calls >= n { if vm, ok := f.vms[vmid]; ok { vm.lock = "" } } }) } func TestCreate_repeatReturnsSameIDAndCreatesNothing(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) first := fx.create(fx.req()) if first != formatProviderID(slot(0), testName) { t.Errorf("Create = %q, want the attempt-0 VMID %d", first, slot(0)) } before := len(fx.fake.callLog()) if second := fx.create(fx.req()); second != first { t.Errorf("second Create = %q, want %q", second, first) } repeat := fx.fake.callLog()[before:] if !slices.Equal(repeat, []string{"ClusterResources", "VMConfig", "VMStatus"}) { t.Errorf("repeat Create made %v, want only the listing, the config and the status reads", repeat) } if got := fx.fake.callCount("Clone"); got != 1 { t.Errorf("Clone calls = %d, want 1", got) } if len(fx.logs.matching(`"msg"="proxmox vm adopted"`)) != 1 { t.Errorf("no adoption line:\n%s", strings.Join(fx.logs.all(), "\n")) } } func TestCreate_resumesAfterEachPartialStep(t *testing.T) { t.Parallel() tests := []struct { name string machineType string setup func(fx *fixture, vm *fakeVM) wantCalls map[string]int }{ { name: "clone finished", setup: func(*fixture, *fakeVM) {}, wantCalls: map[string]int{"UploadISO": 1, "SetVMConfig": 1, "Start": 1}, }, { name: "seed uploaded", setup: func(fx *fixture, _ *fakeVM) { upid, _ := fx.fake.UploadISO(context.Background(), fakeNode, fakeISOStorage, seedISOName(testName), strings.NewReader("cut"), 3) pollTask(fx.t, fx.fake, upid) }, wantCalls: map[string]int{"UploadISO": 2, "SetVMConfig": 1, "Start": 1}, }, { name: "config written", setup: func(_ *fixture, vm *fakeVM) { vm.config["ide2"] = seedVolID(fakeISOStorage, testName) + ",media=cdrom" vm.config["boot"] = "order=scsi0;ide2" vm.config["tags"] = proxyTag }, wantCalls: map[string]int{"UploadISO": 1, "SetVMConfig": 1, "Start": 1}, }, { name: "disk resized", machineType: "big", setup: func(_ *fixture, vm *fakeVM) { vm.config["scsi0"] = "local-lvm:vm-1-disk-1,size=8G" }, wantCalls: map[string]int{"UploadISO": 1, "SetVMConfig": 1, "Resize": 0, "Start": 1}, }, { name: "start submitted, still booting", setup: func(_ *fixture, vm *fakeVM) { vm.status, vm.qmpstatus = "running", "prelaunch" }, wantCalls: map[string]int{"UploadISO": 0, "SetVMConfig": 0, "Start": 0}, }, { name: "running", setup: func(_ *fixture, vm *fakeVM) { vm.status, vm.qmpstatus = "running", "running" }, wantCalls: map[string]int{"UploadISO": 0, "SetVMConfig": 0, "Start": 0}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.p.cfg.MachineTypes["big"] = provider.ProxmoxMachineType{Cores: 1, MemoryMiB: 1024, DiskGiB: 8} vm := fx.addOurVM(slot(0), testName, "stopped") delete(vm.config, "tags") tc.setup(fx, vm) req := fx.req() if tc.machineType != "" { req.Placement.MachineType = tc.machineType } if id := fx.create(req); id != formatProviderID(slot(0), testName) { t.Errorf("Create = %q, want the adopted VM", id) } if got := fx.fake.callCount("Clone"); got != 0 { t.Errorf("Clone calls = %d, want 0", got) } for method, want := range tc.wantCalls { if got := fx.fake.callCount(method); got != want { t.Errorf("%s calls = %d, want %d", method, got, want) } } if vm, _ := fx.fake.vmSnapshot(slot(0)); vm.status != "running" { t.Errorf("VM status = %s, want running", vm.status) } if b, _ := fx.fake.iso(fakeISOStorage, seedISOName(testName)); tc.wantCalls["UploadISO"] > 0 && !bytes.Contains(b, []byte(nocloud.SeedLabel)) { t.Errorf("seed on the storage is not a rebuilt ISO (%d bytes)", len(b)) } }) } } // TestCreate_resumesMidCloneVMAtDeterministicVMID is the crash Phase 0 // found: the clone was submitted, the listing shows the VM nameless and // locked, and the retry must wait for it and adopt it rather than probe // past it and clone a duplicate. func TestCreate_resumesMidCloneVMAtDeterministicVMID(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) vm := fx.addOurVM(slot(0), testName, "stopped") delete(vm.config, "tags") vm.lock = "clone" clearLockOnVMConfig(fx.fake, slot(0), 4) if id := fx.create(fx.req()); id != formatProviderID(slot(0), testName) { t.Errorf("Create = %q, want the mid-clone VM at %d", id, slot(0)) } if got := fx.fake.callCount("Clone"); got != 0 { t.Errorf("Clone calls = %d, want 0: the retry cloned a duplicate", got) } if got := fx.fake.callCount("VMConfig"); got < 4 { t.Errorf("VMConfig calls = %d, want the locked VM polled until unlocked", got) } fx.assertRunning(slot(0)) } func TestCreate_takenVMIDInspectedBeforeProbing(t *testing.T) { t.Parallel() tests := []struct { name string setup func(fx *fixture) // wantVMID is the attempt the VM ends up on; -1 is an error. wantAttempt int wantClass error // wantConfigReads is how many VMConfig calls precede the first // Clone: the template, plus one per inspected VM. wantConfigReads int }{ { name: "foreign VM probes on after its config is read", setup: func(fx *fixture) { fx.fake.addVM(slot(0), "someone-else", "running") }, wantAttempt: 1, wantConfigReads: 2, }, { name: "container probes on without a read", setup: func(fx *fixture) { fx.fake.addVM(slot(0), "ct", "running").kind = "lxc" }, wantAttempt: 1, wantConfigReads: 1, }, { name: "template probes on without a read", setup: func(fx *fixture) { fx.fake.addTemplate(slot(0), "other-template") }, wantAttempt: 1, wantConfigReads: 1, }, { name: "ours, nameless in the listing, is adopted", setup: func(fx *fixture) { fx.addOurVM(slot(0), testName, "stopped").lock = "clone" clearLockOnVMConfig(fx.fake, slot(0), 1) }, wantAttempt: 0, wantConfigReads: 2, }, { name: "someone else's clone with our name is permanent", setup: func(fx *fixture) { vm := fx.fake.addVM(slot(0), testName, "stopped") vm.config["description"] = formatDescription("another-uid", testCreatedAt) vm.lock = "clone" clearLockOnVMConfig(fx.fake, slot(0), 1) }, wantAttempt: -1, wantClass: provider.ErrPermanent, wantConfigReads: 2, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) tc.setup(fx) if tc.wantAttempt < 0 { fx.createFails(fx.req(), tc.wantClass) } else if id := fx.create(fx.req()); id != formatProviderID(slot(tc.wantAttempt), testName) { t.Errorf("Create = %q, want attempt %d (VMID %d)", id, tc.wantAttempt, slot(tc.wantAttempt)) } if got := callsBefore(fx.fake.callLog(), "VMConfig", "Clone"); got != tc.wantConfigReads { t.Errorf("VMConfig calls before the clone = %d, want %d", got, tc.wantConfigReads) } }) } } // TestCreate_cloneAlreadyExistsRaceRunsOwnershipCheck: the listing shows // the VMID free, but another Create takes it before our clone lands (F4). func TestCreate_cloneAlreadyExistsRaceRunsOwnershipCheck(t *testing.T) { t.Parallel() tests := []struct { name string racerName string racerUID string wantAttempt int wantClones int }{ {name: "another proxy's clone is waited on and probed past", racerName: "proxy-racer", racerUID: "racer-uid", wantAttempt: 1, wantClones: 2}, {name: "our own clone is adopted", racerName: testName, racerUID: testUID, wantAttempt: 0, wantClones: 1}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) var raced bool fx.fake.hookOn("Clone", func() error { if !raced { raced = true vm := fx.fake.addVM(slot(0), tc.racerName, "stopped") fx.fake.mu.Lock() vm.config["description"] = formatDescription(tc.racerUID, testCreatedAt) vm.lock = "clone" fx.fake.mu.Unlock() } return nil }) clearLockOnVMConfig(fx.fake, slot(0), 3) if id := fx.create(fx.req()); id != formatProviderID(slot(tc.wantAttempt), testName) { t.Errorf("Create = %q, want attempt %d", id, tc.wantAttempt) } if got := fx.fake.callCount("Clone"); got != tc.wantClones { t.Errorf("Clone calls = %d, want %d", got, tc.wantClones) } if vm, _ := fx.fake.vmSnapshot(slot(0)); vm.name != tc.racerName { t.Errorf("VM %d is named %q, want the racer's %q left alone", slot(0), vm.name, tc.racerName) } }) } } func TestCreate_returnsOnlyWhenRunning(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) var polls int mutateOn(fx.fake, "VMStatus", func() { polls++ if vm, ok := fx.fake.vms[slot(0)]; ok && polls < 3 { vm.qmpstatus = "prelaunch" } else if ok { vm.qmpstatus = "running" } }) fx.create(fx.req()) if got := fx.fake.callCount("VMStatus"); got != 3 { t.Errorf("VMStatus calls = %d, want 3: Create must wait through prelaunch", got) } fx.assertRunning(slot(0)) } func TestCreate_vmThatStopsAfterStartIsTransientAtOnce(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.p.budgets.running = time.Hour mutateOn(fx.fake, "VMStatus", func() { fx.fake.vms[slot(0)].status = "stopped" }) msg := fx.createFails(fx.req(), provider.ErrTransient) if !strings.Contains(msg, "stopped right after its start") { t.Errorf("error = %s, want it to say the VM stopped", msg) } if got := fx.fake.callCount("VMStatus"); got != 1 { t.Errorf("VMStatus calls = %d, want 1", got) } } func TestCreate_runningWaitCapIsTransient(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.p.budgets.running = 30 * time.Millisecond mutateOn(fx.fake, "VMStatus", func() { fx.fake.vms[slot(0)].qmpstatus = "prelaunch" }) msg := fx.createFails(fx.req(), provider.ErrTransient) if !strings.Contains(msg, "VM "+strconv.Itoa(slot(0))+" to run") { t.Errorf("error = %s, want it to name the running wait", msg) } } func TestCreate_rejectsUnknownPlacement(t *testing.T) { t.Parallel() tests := []struct { name string nodes []string mutate func(*provider.CreateRequest) want string }{ {name: "unknown zone", mutate: func(r *provider.CreateRequest) { r.Placement.Zone = "pv9-9" }, want: `placement.zone "pv9-9" is not in proxmox.nodes`}, {name: "empty zone with two nodes", nodes: []string{"pv1-1", "pv1-2"}, mutate: func(*provider.CreateRequest) {}, want: "placement.zone is required"}, {name: "unknown machine type", mutate: func(r *provider.CreateRequest) { r.Placement.MachineType = "huge" }, want: `placement.machineType "huge" is not in proxmox.machineTypes`}, {name: "empty machine type", mutate: func(r *provider.CreateRequest) { r.Placement.MachineType = "" }, want: `placement.machineType "" is not in proxmox.machineTypes`}, {name: "unknown image", mutate: func(r *provider.CreateRequest) { r.Placement.Image = "arch" }, want: `placement.image "arch" is not in proxmox.templates`}, {name: "no name", mutate: func(r *provider.CreateRequest) { r.Name = "" }, want: "no instance name"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) if tc.nodes != nil { fx.p.cfg.Nodes = tc.nodes } req := fx.req() tc.mutate(&req) if msg := fx.createFails(req, provider.ErrPermanent); !strings.Contains(msg, tc.want) { t.Errorf("error = %s, want it to contain %s", msg, tc.want) } if got := fx.fake.callLog(); len(got) != 0 { t.Errorf("calls = %v, want none before placement is valid", got) } }) } } func TestCreate_zoneOptionalWithOneNode(t *testing.T) { t.Parallel() for _, zone := range []string{"", fakeNode} { t.Run("zone "+strconv.Quote(zone), func(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) req := fx.req() req.Placement.Zone = zone fx.create(req) if vm := fx.assertRunning(slot(0)); vm.node != fakeNode { t.Errorf("VM node = %s, want %s", vm.node, fakeNode) } }) } } func TestCreate_regionIgnored(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) req := fx.req() req.Placement.Region = "eu-west-1" if id := fx.create(req); id != formatProviderID(slot(0), testName) { t.Errorf("Create = %q, want the same VM as without a region", id) } } func TestCreate_adoptionWithForeignUIDIsPermanent(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.fake.addVM(testVMID, testName, "running").config["description"] = formatDescription("another-uid", testCreatedAt) msg := fx.createFails(fx.req(), provider.ErrPermanent) for _, want := range []string{"150042", "another-uid", testUID} { if !strings.Contains(msg, want) { t.Errorf("error = %s, want it to name %s", msg, want) } } if got := fx.fake.callCount("Clone") + fx.fake.callCount("Start"); got != 0 { t.Errorf("Clone and Start calls = %d, want 0", got) } } func TestCreate_adoptsOnlyMarkedVMs(t *testing.T) { t.Parallel() tests := []struct { name string description string wantClass error }{ {name: "no description is refused", description: "", wantClass: provider.ErrPermanent}, {name: "admin notes only are refused", description: "owner=alice\nticket=OPS-1", wantClass: provider.ErrPermanent}, {name: "managed false is refused", description: provider.LabelManaged + "=false\n" + provider.LabelUID + "=" + testUID, wantClass: provider.ErrPermanent}, {name: "mark with an empty uid is adopted", description: provider.LabelManaged + "=true"}, {name: "mark with our uid is adopted", description: formatDescription(testUID, testCreatedAt)}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) if tc.description != "" { fx.fake.addVM(testVMID, testName, "stopped").config["description"] = tc.description } else { fx.fake.addVM(testVMID, testName, "stopped") } if tc.wantClass != nil { if msg := fx.createFails(fx.req(), tc.wantClass); !strings.Contains(msg, "no proxy-operator mark") { t.Errorf("error = %s, want it to say the VM is unmarked", msg) } if vm, _ := fx.fake.vmSnapshot(testVMID); vm.status != "stopped" || fx.fake.callCount("SetVMConfig") != 0 { t.Error("an unmarked VM was touched") } return } if id := fx.create(fx.req()); id != fx.id() { t.Errorf("Create = %q, want the adopted %q", id, fx.id()) } }) } } // TestCreate_hibernatedVMIsNotWaitedOn: a suspended lock is not a task and // never clears, so the ownership check judges the VM at once instead of // waiting out the clone budget. func TestCreate_hibernatedVMIsNotWaitedOn(t *testing.T) { t.Parallel() t.Run("ours is permanent", func(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.p.budgets.clone = time.Hour fx.addOurVM(testVMID, testName, "stopped").lock = lockSuspended if msg := fx.createFails(fx.req(), provider.ErrPermanent); !strings.Contains(msg, "hibernated") { t.Errorf("error = %s, want it to say the VM is hibernated", msg) } if got := fx.fake.callCount("VMConfig"); got != 1 { t.Errorf("VMConfig calls = %d, want 1", got) } }) t.Run("foreign is probed past", func(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.p.budgets.clone = time.Hour fx.fake.addVM(slot(0), "someone-else", "stopped").lock = lockSuspended if id := fx.create(fx.req()); id != formatProviderID(slot(1), testName) { t.Errorf("Create = %q, want attempt 1", id) } }) } func TestCreate_duplicateNameIsPermanent(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.addOurVM(testVMID, testName, "running") fx.addOurVM(testVMID+7, testName, "stopped") msg := fx.createFails(fx.req(), provider.ErrPermanent) if !strings.Contains(msg, "150042, 150049") { t.Errorf("error = %s, want it to name both VMIDs", msg) } if got := fx.fake.callLog(); !slices.Equal(got, []string{"ClusterResources"}) { t.Errorf("calls = %v, want only the listing", got) } } func TestCreate_vmidCollisionProbesForward(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.fake.addVM(slot(0), "someone-else", "running") fx.fake.addVM(slot(1), "someone-else-too", "stopped") if id := fx.create(fx.req()); id != formatProviderID(slot(2), testName) { t.Errorf("Create = %q, want attempt 2 (VMID %d)", id, slot(2)) } if got := len(fx.logs.matching("probing on")); got != 2 { t.Errorf("probe lines = %d, want 2", got) } } func TestCreate_vmidRangeExhaustedIsTransient(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.p.cfg.VMIDRange = &provider.ProxmoxVMIDRange{Min: 100000, Max: 100003} for vmid := 100000; vmid <= 100003; vmid++ { fx.fake.addVM(vmid, "someone-"+strconv.Itoa(vmid), "running") } msg := fx.createFails(fx.req(), provider.ErrTransient) if !strings.Contains(msg, "no free VMID") || !strings.Contains(msg, "100000..100003") { t.Errorf("error = %s, want it to name the range", msg) } if got := fx.fake.callCount("Clone"); got != 0 { t.Errorf("Clone calls = %d, want 0", got) } if got := fx.fake.callCount("VMConfig"); got != 5 { t.Errorf("VMConfig calls = %d, want the template and each of the 4 VMIDs once", got) } } func TestCreate_waitsForCloneLockOnRetry(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.p.budgets.clone = 30 * time.Millisecond fx.fake.cloneLeavesLock = true if msg := fx.createFails(fx.req(), provider.ErrTransient); !strings.Contains(msg, "the lock on VM") { t.Errorf("first error = %s, want the lock wait to run out", msg) } fx.fake.mu.Lock() fx.fake.cloneLeavesLock = false fx.fake.mu.Unlock() fx.p.budgets.clone = time.Second clearLockOnVMConfig(fx.fake, slot(0), 4) if id := fx.create(fx.req()); id != formatProviderID(slot(0), testName) { t.Errorf("retry Create = %q, want the locked VM adopted", id) } if got := fx.fake.callCount("Clone"); got != 1 { t.Errorf("Clone calls = %d, want 1", got) } fx.assertRunning(slot(0)) } func TestCreate_cloneTaskFailureIsTransient(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.fake.cloneFailsWith = "clone failed: storage 'local-lvm' is full" msg := fx.createFails(fx.req(), provider.ErrTransient) if !strings.Contains(msg, "clone failed: storage 'local-lvm' is full") { t.Errorf("error = %s, want the task's exitstatus", msg) } if len(fx.logs.matching(`"msg"="proxmox task failed"`)) != 1 { t.Errorf("no task-failed line:\n%s", strings.Join(fx.logs.all(), "\n")) } fx.fake.mu.Lock() fx.fake.cloneFailsWith = "" fx.fake.mu.Unlock() if id := fx.create(fx.req()); id != formatProviderID(slot(0), testName) { t.Errorf("retry Create = %q, want attempt 0 again", id) } } func TestCreate_cloneTaskTimeoutIsTransient(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.p.budgets.clone = 30 * time.Millisecond fx.fake.taskPolls = 1 << 30 msg := fx.createFails(fx.req(), provider.ErrTransient) if !strings.Contains(msg, "waiting for task UPID:"+fakeNode) || !strings.Contains(msg, "not done after") { t.Errorf("error = %s, want it to name the task and the budget", msg) } } // TestCreate_markWrittenInCloneCall reads the VM when the seed upload // starts, between the clone and the config write: the mark is already // there, so it came with the clone. func TestCreate_markWrittenInCloneCall(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) now := time.Date(2026, 9, 23, 12, 30, 0, 0, time.UTC) fx.p.now = func() time.Time { return now } var atUpload string mutateOn(fx.fake, "UploadISO", func() { atUpload = fx.fake.vms[slot(0)].config["description"] }) fx.create(fx.req()) want := formatDescription(testUID, now) if atUpload != want { t.Errorf("description after the clone = %q, want %q", atUpload, want) } vm, _ := fx.fake.vmSnapshot(slot(0)) if m := parseDescription(vm.config["description"]); !m.Managed || m.UID != testUID || !m.CreatedAt.Equal(now) { t.Errorf("mark read back = %+v", m) } } func TestCreate_tagSetInConfigStep(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) atUpload := "unset" mutateOn(fx.fake, "UploadISO", func() { atUpload = fx.fake.vms[slot(0)].config["tags"] }) fx.create(fx.req()) if atUpload != "" { t.Errorf("tags after the clone = %q, want none: tags are not a clone parameter", atUpload) } if vm, _ := fx.fake.vmSnapshot(slot(0)); vm.config["tags"] != proxyTag { t.Errorf("tags = %q, want %q", vm.config["tags"], proxyTag) } } func TestCreate_seedAlwaysRebuilt(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.create(fx.req()) fx.fake.mu.Lock() fx.fake.vms[slot(0)].status = "stopped" fx.fake.isos[fakeISOStorage][seedISOName(testName)] = []byte("cut") fx.fake.mu.Unlock() fx.create(fx.req()) if got := fx.fake.callCount("UploadISO"); got != 2 { t.Errorf("UploadISO calls = %d, want 2", got) } if again, _ := fx.fake.iso(fakeISOStorage, seedISOName(testName)); !bytes.Contains(again, []byte(nocloud.SeedLabel)) { t.Errorf("seed after the retry has %d bytes and no %s label, want it rebuilt", len(again), nocloud.SeedLabel) } } func TestCreate_seedUploadOverwritesWithoutDelete(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.fake.mu.Lock() fx.fake.isos[fakeISOStorage] = map[string][]byte{seedISOName(testName): []byte("stale")} fx.fake.mu.Unlock() fx.create(fx.req()) if got := fx.fake.callCount("DeleteVolume"); got != 0 { t.Errorf("DeleteVolume calls = %d, want 0: an upload overwrites (F12)", got) } b, _ := fx.fake.iso(fakeISOStorage, seedISOName(testName)) if !bytes.Contains(b, []byte(nocloud.SeedLabel)) { t.Errorf("seed has %d bytes and no %s label, want the new ISO", len(b), nocloud.SeedLabel) } } func TestCreate_seedUploadedBeforeConfig(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.fake.taskPolls = 2 fx.create(fx.req()) log := fx.fake.callLog() up, cfg := slices.Index(log, "UploadISO"), slices.Index(log, "SetVMConfig") if up < 0 || cfg < up { t.Fatalf("call log = %v, want UploadISO before SetVMConfig", log) } if got := callsBefore(log[up:], "TaskStatus", "SetVMConfig"); got != 3 { t.Errorf("upload task polls before the config write = %d, want 3", got) } } func TestCreate_configSetsCoresMemoryAgentAndSeed(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.fake.mu.Lock() tmpl := fx.fake.vms[testTemplateVMID].config tmpl["agent"], tmpl["cores"], tmpl["memory"] = "0", "4", "8192" fx.fake.mu.Unlock() fx.create(fx.req()) vm, _ := fx.fake.vmSnapshot(slot(0)) want := map[string]string{ "cores": "1", "memory": "1024", "agent": "1", "ide2": "proxy-seeds:iso/" + testName + "-seed.iso,media=cdrom", "tags": proxyTag, } for k, v := range want { if vm.config[k] != v { t.Errorf("%s = %q, want %q", k, vm.config[k], v) } } } func TestCreate_bridgeOverrideKeepsMAC(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.p.cfg.Bridge = "vmbr4" fx.create(fx.req()) if vm, _ := fx.fake.vmSnapshot(slot(0)); vm.config["net0"] != "virtio=BC:24:11:48:11:46,bridge=vmbr4" { t.Errorf("net0 = %q, want the MAC kept and the bridge replaced", vm.config["net0"]) } } // TestCreate_noBridgeOverrideLeavesNet0 changes net0 just before the config // write: a write that carried net0 would put the cloned value back. func TestCreate_noBridgeOverrideLeavesNet0(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) const changed = "virtio=BC:24:11:48:11:46,bridge=vmbr9" mutateOn(fx.fake, "SetVMConfig", func() { fx.fake.vms[slot(0)].config["net0"] = changed }) fx.create(fx.req()) if vm, _ := fx.fake.vmSnapshot(slot(0)); vm.config["net0"] != changed { t.Errorf("net0 = %q, want it left out of the config write", vm.config["net0"]) } } func TestCreate_vlanTagAppendedKeepsMACAndOptions(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.p.cfg.VLANTag = 4 fx.fake.mu.Lock() fx.fake.vms[testTemplateVMID].config["net0"] = "virtio=BC:24:11:48:11:46,bridge=vmbr0,firewall=1,mtu=1500" fx.fake.mu.Unlock() fx.create(fx.req()) if vm, _ := fx.fake.vmSnapshot(slot(0)); vm.config["net0"] != "virtio=BC:24:11:48:11:46,bridge=vmbr0,firewall=1,mtu=1500,tag=4" { t.Errorf("net0 = %q, want tag=4 appended and everything else in order", vm.config["net0"]) } } func TestRewriteNet0_table(t *testing.T) { t.Parallel() const mac = "virtio=BC:24:11:48:11:46" tests := []struct { name string net0 string bridge string tag int want string }{ {name: "nothing configured", net0: mac + ",bridge=vmbr0", want: mac + ",bridge=vmbr0"}, {name: "bridge replaced in place", net0: mac + ",bridge=vmbr0,firewall=1", bridge: "vmbr4", want: mac + ",bridge=vmbr4,firewall=1"}, {name: "tag appended", net0: mac + ",bridge=vmbr0", tag: 4, want: mac + ",bridge=vmbr0,tag=4"}, {name: "tag replaced in place", net0: mac + ",bridge=vmbr0,tag=2,firewall=1", tag: 4, want: mac + ",bridge=vmbr0,tag=4,firewall=1"}, {name: "both appended", net0: mac, bridge: "vmbr4", tag: 4, want: mac + ",bridge=vmbr4,tag=4"}, {name: "the captured VLAN template", net0: "virtio=BC:24:11:2F:97:A0,bridge=vmbr0,tag=4", bridge: "vmbr1", tag: 7, want: "virtio=BC:24:11:2F:97:A0,bridge=vmbr1,tag=7"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := rewriteNet0(tc.net0, tc.bridge, tc.tag); got != tc.want { t.Errorf("rewriteNet0(%q, %q, %d) = %q, want %q", tc.net0, tc.bridge, tc.tag, got, tc.want) } }) } } // TestCreate_templateShapeRefusedBeforeClone: the clone copies the // template's config, so what the config or resize step would refuse on the // clone is refused on the template, before a VM exists that a fixed // template could no longer help. func TestCreate_templateShapeRefusedBeforeClone(t *testing.T) { t.Parallel() tests := []struct { name string setup func(fx *fixture, tmpl map[string]string) want string bigger bool }{ { name: "no net0 with a bridge configured", setup: func(fx *fixture, tmpl map[string]string) { fx.p.cfg.Bridge = "vmbr4" delete(tmpl, "net0") }, want: "no net0", }, { name: "no boot disk with diskGiB set", setup: func(_ *fixture, tmpl map[string]string) { delete(tmpl, "scsi0") }, want: "no boot disk", bigger: true, }, { name: "boot disk without a size", setup: func(_ *fixture, tmpl map[string]string) { tmpl["scsi0"] = "local-lvm:base-9001-disk-1" }, want: "no readable size", bigger: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.p.cfg.MachineTypes["big"] = provider.ProxmoxMachineType{Cores: 1, MemoryMiB: 1024, DiskGiB: 8} fx.fake.mu.Lock() tc.setup(fx, fx.fake.vms[testTemplateVMID].config) fx.fake.mu.Unlock() req := fx.req() if tc.bigger { req.Placement.MachineType = "big" } if msg := fx.createFails(req, provider.ErrPermanent); !strings.Contains(msg, tc.want) { t.Errorf("error = %s, want %s", msg, tc.want) } if got := fx.fake.callCount("Clone"); got != 0 { t.Errorf("Clone calls = %d, want 0", got) } }) } } func TestCreate_resizeWaitsForTask(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.p.cfg.MachineTypes["big"] = provider.ProxmoxMachineType{Cores: 1, MemoryMiB: 1024, DiskGiB: 8} fx.fake.taskPolls = 2 req := fx.req() req.Placement.MachineType = "big" fx.create(req) log := fx.fake.callLog() rs := slices.Index(log, "Resize") if rs < 0 { t.Fatalf("call log = %v, want a Resize", log) } if got := callsBefore(log[rs:], "TaskStatus", "Start"); got != 3 { t.Errorf("resize task polls before the start = %d, want 3", got) } if vm, _ := fx.fake.vmSnapshot(slot(0)); vm.config["scsi0"] != "local-lvm:vm-"+strconv.Itoa(slot(0))+"-disk-1,size=8G" { t.Errorf("scsi0 = %q, want size=8G", vm.config["scsi0"]) } } func TestCreate_resizeSkippedWhenNotLarger(t *testing.T) { t.Parallel() for _, diskGiB := range []int64{0, 2, 3} { t.Run("diskGiB "+strconv.FormatInt(diskGiB, 10), func(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.p.cfg.MachineTypes["sized"] = provider.ProxmoxMachineType{Cores: 1, MemoryMiB: 1024, DiskGiB: diskGiB} req := fx.req() req.Placement.MachineType = "sized" fx.create(req) if got := fx.fake.callCount("Resize"); got != 0 { t.Errorf("Resize calls = %d, want 0 for a 3G disk", got) } if vm, _ := fx.fake.vmSnapshot(slot(0)); diskOption(vm.config["scsi0"], "size") != "3G" { t.Errorf("scsi0 = %q, want 3G kept", vm.config["scsi0"]) } }) } } func TestBootDisk_table(t *testing.T) { t.Parallel() const ( disk = "local-lvm:vm-1-disk-1,size=3G" seed = "proxy-seeds:iso/proxy-x-seed.iso,media=cdrom" cdrom = "none,media=cdrom" ) tests := []struct { name string cfg vmConfig want string wantOK bool }{ {name: "order names the disk", cfg: vmConfig{"boot": "order=scsi0", "scsi0": disk}, want: "scsi0", wantOK: true}, {name: "seed appended by Proxmox", cfg: vmConfig{"boot": "order=scsi0;ide2", "scsi0": disk, "ide2": seed}, want: "scsi0", wantOK: true}, {name: "cdrom first in order", cfg: vmConfig{"boot": "order=ide2;scsi0", "scsi0": disk, "ide2": cdrom}, want: "scsi0", wantOK: true}, {name: "network first in order", cfg: vmConfig{"boot": "order=net0;virtio0", "net0": "virtio=BC:24:11:48:11:46", "virtio0": disk}, want: "virtio0", wantOK: true}, {name: "second disk first in order", cfg: vmConfig{"boot": "order=scsi1;scsi0", "scsi0": disk, "scsi1": disk}, want: "scsi1", wantOK: true}, {name: "order names a missing disk", cfg: vmConfig{"boot": "order=scsi3;sata0", "sata0": disk}, want: "sata0", wantOK: true}, {name: "legacy boot with bootdisk", cfg: vmConfig{"boot": "cdn", "bootdisk": "virtio0", "virtio0": disk, "scsi0": disk}, want: "virtio0", wantOK: true}, {name: "no boot line, first known slot", cfg: vmConfig{"sata0": disk, "ide0": disk}, want: "sata0", wantOK: true}, {name: "ide0 disk is a disk", cfg: vmConfig{"ide0": disk, "ide2": seed}, want: "ide0", wantOK: true}, {name: "efidisk is not a boot disk", cfg: vmConfig{"efidisk0": "local-lvm:vm-1-disk-0,size=4M", "ide2": seed}}, {name: "cdrom only", cfg: vmConfig{"boot": "order=ide0", "ide0": cdrom}}, {name: "empty", cfg: vmConfig{}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() got, ok := bootDisk(tc.cfg) if got != tc.want || ok != tc.wantOK { t.Errorf("bootDisk = %q, %v; want %q, %v", got, ok, tc.want, tc.wantOK) } }) } } func TestBootDisk_capturedClone(t *testing.T) { t.Parallel() cfg := capturedConfig(t, "vm_config_clone") if got, ok := bootDisk(cfg); got != "scsi0" || !ok { t.Errorf("bootDisk of the captured clone = %q, %v; want scsi0", got, ok) } if got, ok := diskBytes(cfg["scsi0"]); got != 3<<30 || !ok { t.Errorf("diskBytes of the captured scsi0 = %d, %v; want 3 GiB", got, ok) } } func TestDiskBytes_table(t *testing.T) { t.Parallel() tests := []struct { disk string want int64 wantOK bool }{ {disk: "local-lvm:vm-1-disk-1,size=3G", want: 3 << 30, wantOK: true}, {disk: "local-lvm:vm-1-disk-0,efitype=4m,size=4M", want: 4 << 20, wantOK: true}, {disk: "local:1/vm-1-disk-0.qcow2,size=512K", want: 512 << 10, wantOK: true}, {disk: "big:vm-1-disk-1,size=2T", want: 2 << 40, wantOK: true}, {disk: "raw:vm-1-disk-1,size=1073741824", want: 1 << 30, wantOK: true}, {disk: "odd:vm-1-disk-1,size=1.5G", want: 3 << 29, wantOK: true}, {disk: "local-lvm:vm-1-disk-1"}, {disk: "local-lvm:vm-1-disk-1,size="}, {disk: "local-lvm:vm-1-disk-1,size=lots"}, {disk: "local-lvm:vm-1-disk-1,size=-1G"}, } for _, tc := range tests { t.Run(tc.disk, func(t *testing.T) { t.Parallel() got, ok := diskBytes(tc.disk) if got != tc.want || ok != tc.wantOK { t.Errorf("diskBytes(%q) = %d, %v; want %d, %v", tc.disk, got, ok, tc.want, tc.wantOK) } }) } } func TestCreate_startTaskFailureIsTransient(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.fake.startFailsWith = "start failed: QEMU exited with code 1" if msg := fx.createFails(fx.req(), provider.ErrTransient); !strings.Contains(msg, "start failed: QEMU exited with code 1") { t.Errorf("error = %s, want the task's exitstatus", msg) } } func TestCreate_templateMissingIsPermanent(t *testing.T) { t.Parallel() fx := newFixture(t, 1) msg := fx.createFails(fx.req(), provider.ErrPermanent) if !strings.Contains(msg, `placement.image "debian-13": template VM 9001 does not exist`) { t.Errorf("error = %s, want it to name the image and the VMID", msg) } if got := fx.fake.callCount("Clone"); got != 0 { t.Errorf("Clone calls = %d, want 0", got) } } // capturedConfig loads a config capture the way the client decodes it. func capturedConfig(t *testing.T, name string) vmConfig { t.Helper() cfg, err := decodeConfig(decodeData[map[string]json.RawMessage](t, name)) if err != nil { t.Fatalf("decoding %s: %v", name, err) } return cfg } func TestCreate_templateWithCloudInitDriveIsPermanent(t *testing.T) { t.Parallel() tests := []struct { name string setup func(vm *fakeVM) want string }{ { name: "the captured Ubuntu template", setup: func(vm *fakeVM) { vm.config = capturedConfig(t, "vm_config_template_ubuntu") }, want: "cloud-init drive on ide2", }, { name: "cloud-init drive on another slot", setup: func(vm *fakeVM) { vm.config["sata1"] = "local-lvm:vm-9001-cloudinit,media=cdrom" }, want: "cloud-init drive on sata1", }, { name: "not a template", setup: func(vm *fakeVM) { vm.template = false }, want: "VM 9001 is not a template", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.fake.mu.Lock() tc.setup(fx.fake.vms[testTemplateVMID]) fx.fake.mu.Unlock() if msg := fx.createFails(fx.req(), provider.ErrPermanent); !strings.Contains(msg, tc.want) { t.Errorf("error = %s, want %s", msg, tc.want) } if got := fx.fake.callCount("Clone"); got != 0 { t.Errorf("Clone calls = %d, want 0", got) } }) } } // TestCreate_adoptionDoesNotNeedTheTemplate: a template may be deleted once // its clones exist (spec entry 4), so a retry that finds its VM must not // fail on the template. func TestCreate_adoptionDoesNotNeedTheTemplate(t *testing.T) { t.Parallel() for _, status := range []string{"running", "stopped"} { t.Run(status, func(t *testing.T) { t.Parallel() fx := newFixture(t, 1) fx.addOurVM(testVMID, testName, status) if id := fx.create(fx.req()); id != fx.id() { t.Errorf("Create = %q, want %q", id, fx.id()) } fx.assertRunning(testVMID) }) } } func TestCreate_cloneTargetAndStorage(t *testing.T) { t.Parallel() tests := []struct { name string zone string storage string wantTarget string wantStorage string wantNode string }{ {name: "template's node, template's storage", zone: fakeNode, wantNode: fakeNode}, {name: "another node, configured storage", zone: "pv1-2", storage: "fast-lvm", wantTarget: "pv1-2", wantStorage: "fast-lvm", wantNode: "pv1-2"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 2) fx.p.api = observe(fx.fake, testProvider) fx.p.cfg.Nodes = []string{fakeNode, "pv1-2"} fx.p.cfg.Storage = tc.storage req := fx.req() req.Placement.Zone = tc.zone fx.create(req) lines := fx.logs.matching(`"call"="Clone"`) if len(lines) != 1 { t.Fatalf("clone call lines = %d, want 1", len(lines)) } for _, want := range []string{`"target"="` + tc.wantTarget + `"`, `"storage"="` + tc.wantStorage + `"`, `"node"="` + fakeNode + `"`} { if !strings.Contains(lines[0], want) { t.Errorf("clone call line lacks %s: %s", want, lines[0]) } } if vm := fx.assertRunning(slot(0)); vm.node != tc.wantNode { t.Errorf("VM node = %s, want %s", vm.node, tc.wantNode) } }) } } // TestCreate_adoptedVMStaysOnItsNode: the ID is location-free, so a VM that // migrated is carried on where it is, not where placement.zone points. func TestCreate_adoptedVMStaysOnItsNode(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.addOurVM(testVMID, testName, "stopped").node = "pv1-2" if id := fx.create(fx.req()); id != fx.id() { t.Errorf("Create = %q, want %q", id, fx.id()) } if vm := fx.assertRunning(testVMID); vm.node != "pv1-2" { t.Errorf("VM node = %s, want pv1-2", vm.node) } if lines := fx.logs.matching(`"msg"="proxmox vm ensured running"`); len(lines) != 1 || !strings.Contains(lines[0], `"node"="pv1-2"`) { t.Errorf("outcome lines = %v, want one naming pv1-2", lines) } } func TestCreate_notExistInsideCreateIsTransientNotNotFound(t *testing.T) { t.Parallel() for _, method := range []string{"SetVMConfig", "Start", "VMStatus"} { t.Run(method, func(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.fake.failWith(method, errVMMissing(fakeNode, slot(0))) fx.createFails(fx.req(), provider.ErrTransient) }) } } func TestCreate_cloudInitSentinelNeverLoggedOrInErrors(t *testing.T) { t.Parallel() const sentinel = "SENTINEL-s3cr3t-passw0rd-7f3a" tests := []struct { name string setup func(fx *fixture) }{ {name: "success", setup: func(*fixture) {}}, {name: "upload refused", setup: func(fx *fixture) { fx.fake.failWith("UploadISO", fakeErr(500, "fake: upload failed")) }}, {name: "upload task failed", setup: func(fx *fixture) { fx.fake.hookOn("TaskStatus", func() error { if fx.fake.callCount("SetVMConfig") == 0 && fx.fake.callCount("UploadISO") > 0 { return fakeErr(596, "fake: broken pipe") } return nil }) }}, {name: "config refused", setup: func(fx *fixture) { fx.fake.failWith("SetVMConfig", errLocked("backup")) }}, {name: "start failed", setup: func(fx *fixture) { fx.fake.startFailsWith = "start failed" }}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 2) fx.p.api = observe(fx.fake, testProvider) tc.setup(fx) req := fx.req() req.CloudInit = "#cloud-config\nwrite_files:\n - content: " + sentinel + "\n" _, err := fx.p.Create(fx.ctx, req) if err != nil && strings.Contains(err.Error(), sentinel) { t.Errorf("error carries the cloud-init: %v", err) } lines := fx.logs.all() for _, l := range lines { if strings.Contains(l, sentinel) { t.Errorf("log line carries the cloud-init: %s", l) } } if got := fx.logs.matching(`"cloudInitBytes"=` + strconv.Itoa(len(req.CloudInit))); len(got) != 1 { t.Errorf("cloudInitBytes lines = %d, want 1:\n%s", len(got), strings.Join(lines, "\n")) } }) } } func TestCreate_concurrentSameNameSerialised(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) release := fx.fake.blockOn("Clone") ids := make([]string, 2) errs := make([]error, 2) var wg sync.WaitGroup for i := range ids { wg.Go(func() { ids[i], errs[i] = fx.p.Create(fx.ctx, fx.req()) }) if i == 0 { for fx.fake.callCount("Clone") == 0 { time.Sleep(time.Millisecond) } } } close(release) wg.Wait() for i, err := range errs { if err != nil { t.Fatalf("Create %d error = %v", i, err) } } if ids[0] != ids[1] { t.Errorf("ids = %v, want the same VM for both", ids) } if got := fx.fake.callCount("Clone"); got != 1 { t.Errorf("Clone calls = %d, want 1", got) } } func TestCreate_cancelledMidWaitReturnsPromptly(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.p.budgets = defaultBudgets() fx.p.budgets.poll = time.Millisecond fx.fake.taskPolls = 1 << 30 ctx, cancel := context.WithCancel(fx.ctx) defer cancel() fx.fake.hookOn("TaskStatus", func() error { cancel() return nil }) start := time.Now() _, err := fx.p.Create(ctx, fx.req()) if elapsed := time.Since(start); elapsed > 5*time.Second { t.Errorf("Create took %v after cancellation", elapsed) } assertProviderError(t, err, "create", provider.ErrTransient) if !errors.Is(err, context.Canceled) { t.Errorf("error = %v, want it to wrap context.Canceled", err) } } func TestCreate_outcomeLine(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 1) fx.create(fx.req()) lines := fx.logs.matching(`"msg"="proxmox vm ensured running"`) if len(lines) != 1 { t.Fatalf("outcome lines = %d, want 1:\n%s", len(lines), strings.Join(fx.logs.all(), "\n")) } for _, want := range []string{`"vmid"=` + strconv.Itoa(slot(0)), `"name"="` + testName + `"`, `"node"="` + fakeNode + `"`, `"provider"="` + testProvider + `"`} { if !strings.Contains(lines[0], want) { t.Errorf("outcome line lacks %s: %s", want, lines[0]) } } } func TestCreate_quietAtV0(t *testing.T) { t.Parallel() fx := newCreateFixture(t, 0) fx.p.api = observe(fx.fake, testProvider) fx.create(fx.req()) if lines := fx.logs.all(); len(lines) != 0 { t.Errorf("V(0) lines = %v, want none", lines) } }