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>
1270 lines
42 KiB
Go
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)
|
|
}
|
|
}
|