Files
egress-proxies-operator/internal/provider/proxmox/create_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

1276 lines
43 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)
first := make(chan struct{})
var wg sync.WaitGroup
wg.Go(func() {
defer close(first)
ids[0], errs[0] = fx.p.Create(fx.ctx, fx.req())
})
for fx.fake.callCount("Clone") == 0 {
select {
case <-first:
t.Fatalf("first Create returned %v without cloning", errs[0])
case <-time.After(time.Millisecond):
}
}
wg.Go(func() { ids[1], errs[1] = fx.p.Create(fx.ctx, fx.req()) })
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()
var err error
within(t, 10*time.Second, func() { _, 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)
}
}