Files
egress-proxies-operator/internal/provider/proxmox/create_test.go
T
kacerrandClaude Opus 5.5 34f132d421 proxmox: Create by cloning a template, seed upload, config, start
Create validates placement, then under the per-name lock adopts the VM a
previous attempt left (by name, or at a taken VMID after the ownership
check of spec entry 2) or full-clones the template onto the UID's
deterministic VMID with the mark in the clone call. It then uploads the
seed over any existing file, writes cores, memory, agent, tag, ide2 and
net0 when configured, grows the boot disk when diskGiB is larger, starts,
and waits for running/running.

waitUnlocked no longer waits on a suspended lock: a hibernated VM is
judged at once, so ours is Permanent and a foreign one is probed past.
The template is checked only on the allocation path, and its shape
(net0, boot disk size) is refused before any clone exists. Spec entries
2, 4 and 10 updated to match.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:32:31 +00:00

1270 lines
42 KiB
Go

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