Files
egress-proxies-operator/internal/provider/proxmox/fake_test.go
T
kacerrandClaude Opus 5.5 2f89c1fffb proxmox: Delete with seed cleanup, health Checker, CRD type comment
Delete stops and destroys a VM of ours under the per-name lock, then
deletes its seed on the VM's node, or on every node when no VM of ours
was left. A hibernated VM of ours is Permanent, as in Create. The
Checker verifies each node concurrently (version, node status, ISO
storage, templates) and adds CertificateInvalid, CredentialsUnavailable,
StorageNotFound and TemplateNotFound to the shared reasons. The
ProviderHealth type comment gains proxmox.

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

911 lines
27 KiB
Go

package proxmox
import (
"context"
"errors"
"fmt"
"io"
"maps"
"slices"
"strconv"
"strings"
"sync"
"testing"
)
// fakeAPI is the in-memory cluster of spec entry 17: VMs, tasks and ISOs,
// with lock simulation (a clone leaves a nameless locked entry until its
// task completes), task polls, per-method errors and blocking, and a call
// log. Error texts Proxmox was seen to send are reproduced exactly and
// checked against the captures by TestFakeAPI_errorsMatchCaptures; texts
// recon never produced start with "fake:".
type fakeAPI struct {
mu sync.Mutex
vms map[int]*fakeVM
tasks map[string]*fakeTask
isos map[string]map[string][]byte // storage, then file name
nodes map[string]nodeStatus
storages map[string]storageStatus
version versionInfo
taskSeq int
// hooks returns the error a method fails with before touching state.
hooks map[string]func() error
// block makes a method wait on its channel (or ctx) before running.
block map[string]chan struct{}
calls map[string]int
log []string
// taskPolls is how many TaskStatus polls answer "running" before a
// task stops.
taskPolls int
// cloneFailsWith, startFailsWith and destroyFailsWith, when set, are
// the exitstatus of every clone, start or destroy task. A failed clone
// removes its VM; a failed destroy keeps it.
cloneFailsWith, startFailsWith, destroyFailsWith string
// agentNotRunning makes the agent of every running VM with agent=1
// answer "QEMU guest agent is not running".
agentNotRunning bool
// cloneLeavesLock keeps lock=clone after a clone task ends, as an
// interrupted clone would (F15).
cloneLeavesLock bool
}
type fakeVM struct {
node, name, status, qmpstatus, lock string
// kind is the resource type; empty is "qemu". Any other kind, such as
// "lxc", is listed but invisible to the qemu endpoints.
kind string
template bool
config map[string]string
ifaces []agentInterface
}
type fakeTask struct {
node string
polls int
exit string
onDone func()
kind string
id string
done bool
}
const (
fakeNode = "pv1-1"
fakeISOStorage = "proxy-seeds"
)
var _ api = (*fakeAPI)(nil)
func newFakeAPI() *fakeAPI {
var node nodeStatus
node.PVEVersion = "pve-manager/9.2.20/49318c671b82f31e"
node.CurrentKernel.Release = "7.0.14-17-pve"
return &fakeAPI{
vms: make(map[int]*fakeVM),
tasks: make(map[string]*fakeTask),
isos: make(map[string]map[string][]byte),
nodes: map[string]nodeStatus{fakeNode: node},
storages: map[string]storageStatus{fakeISOStorage: {Type: "dir", Content: "iso", Active: 1, Enabled: 1, Total: 100 << 30, Used: 10 << 30, Avail: 90 << 30}},
version: versionInfo{Version: "9.2.20", Release: "9.2", RepoID: "49318c671b82f31e"},
hooks: make(map[string]func() error),
block: make(map[string]chan struct{}),
calls: make(map[string]int),
}
}
// addVM places a stopped or running VM cloned from a template like 9001 on
// fakeNode, without a description; tests add the mark they need.
func (f *fakeAPI) addVM(vmid int, name, status string) *fakeVM {
f.mu.Lock()
defer f.mu.Unlock()
vm := &fakeVM{
node: fakeNode,
name: name,
status: status,
qmpstatus: status,
config: map[string]string{
"agent": "1",
"boot": "order=scsi0",
"cores": "2",
"memory": "2048",
"net0": "virtio=BC:24:11:48:11:46,bridge=vmbr0",
"scsi0": fmt.Sprintf("local-lvm:vm-%d-disk-1,size=3G", vmid),
},
}
f.vms[vmid] = vm
return vm
}
// addTemplate places a template shaped like 9001 (F19) on fakeNode.
func (f *fakeAPI) addTemplate(vmid int, name string) *fakeVM {
vm := f.addVM(vmid, name, "stopped")
f.mu.Lock()
defer f.mu.Unlock()
vm.template = true
vm.config["scsi0"] = fmt.Sprintf("local-lvm:base-%d-disk-1,size=3G", vmid)
return vm
}
func (f *fakeAPI) failWith(method string, err error) {
f.hookOn(method, func() error { return err })
}
// hookOn runs fn at the start of every call to method, without f.mu held;
// a non-nil result fails the call. A hook that changes state takes f.mu.
func (f *fakeAPI) hookOn(method string, fn func() error) {
f.mu.Lock()
defer f.mu.Unlock()
f.hooks[method] = fn
}
func (f *fakeAPI) blockOn(method string) chan struct{} {
f.mu.Lock()
defer f.mu.Unlock()
ch := make(chan struct{})
f.block[method] = ch
return ch
}
func (f *fakeAPI) callCount(method string) int {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls[method]
}
func (f *fakeAPI) callLog() []string {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(f.log)
}
// iso returns an uploaded file's content.
func (f *fakeAPI) iso(storage, filename string) ([]byte, bool) {
f.mu.Lock()
defer f.mu.Unlock()
b, ok := f.isos[storage][filename]
return b, ok
}
// vmSnapshot returns a copy of the VM at vmid, for assertions.
func (f *fakeAPI) vmSnapshot(vmid int) (fakeVM, bool) {
f.mu.Lock()
defer f.mu.Unlock()
vm, ok := f.vms[vmid]
if !ok {
return fakeVM{}, false
}
c := *vm
c.config = maps.Clone(vm.config)
c.ifaces = slices.Clone(vm.ifaces)
return c, true
}
func fakeErr(status int, msg string) *apiError {
return &apiError{Status: status, Message: msg}
}
// The 500s recon captured (F2, F4, F5, F7).
func errVMMissing(node string, vmid int) *apiError {
return fakeErr(500, fmt.Sprintf("Configuration file 'nodes/%s/qemu-server/%d.conf' does not exist", node, vmid))
}
func errLocked(lock string) *apiError { return fakeErr(500, "VM is locked ("+lock+")") }
func errNewIDInUse(vmid int) *apiError {
return fakeErr(500, fmt.Sprintf("unable to create VM %d: config file already exists", vmid))
}
func errVMNotRunning(vmid int) *apiError {
return fakeErr(500, fmt.Sprintf("VM %d is not running", vmid))
}
// enter records the call, honours a block and runs the hook. It is called
// without f.mu held; state access happens afterwards under the lock.
func (f *fakeAPI) enter(ctx context.Context, method string) error {
f.mu.Lock()
f.calls[method]++
f.log = append(f.log, method)
block := f.block[method]
hook := f.hooks[method]
f.mu.Unlock()
if block != nil {
select {
case <-block:
case <-ctx.Done():
return ctx.Err()
}
}
if hook != nil {
return hook()
}
return nil
}
// vm looks a VM up the way Proxmox does, by node and VMID: a VM on another
// node has no config file on this one.
func (f *fakeAPI) vm(node string, vmid int) (*fakeVM, error) {
vm, ok := f.vms[vmid]
if !ok || vm.node != node || vm.kind != "" {
return nil, errVMMissing(node, vmid)
}
return vm, nil
}
// newTask registers a task that stops after f.taskPolls running answers,
// then runs onDone under f.mu. The UPID has the shape parseUPID reads.
func (f *fakeAPI) newTask(node, kind, id, exit string, onDone func()) string {
f.taskSeq++
upid := fmt.Sprintf("UPID:%s:%08X:%08X:%08X:%s:%s:proxy-operator@pve!operator:", node, 0x100000+f.taskSeq, 0x2800000+f.taskSeq, 0x6AB37800+f.taskSeq, kind, id)
f.tasks[upid] = &fakeTask{node: node, exit: exit, onDone: onDone, kind: kind, id: id}
return upid
}
func (f *fakeAPI) Version(ctx context.Context) (versionInfo, error) {
if err := f.enter(ctx, "Version"); err != nil {
return versionInfo{}, err
}
f.mu.Lock()
defer f.mu.Unlock()
return f.version, nil
}
func (f *fakeAPI) NodeStatus(ctx context.Context, node string) (nodeStatus, error) {
if err := f.enter(ctx, "NodeStatus"); err != nil {
return nodeStatus{}, err
}
f.mu.Lock()
defer f.mu.Unlock()
s, ok := f.nodes[node]
if !ok {
return nodeStatus{}, fakeErr(500, "fake: no such node '"+node+"'")
}
return s, nil
}
func (f *fakeAPI) StorageStatus(ctx context.Context, node, storage string) (storageStatus, error) {
if err := f.enter(ctx, "StorageStatus"); err != nil {
return storageStatus{}, err
}
f.mu.Lock()
defer f.mu.Unlock()
s, ok := f.storages[storage]
if !ok {
return storageStatus{}, fakeErr(500, "fake: storage '"+storage+"' does not exist")
}
return s, nil
}
// ClusterResources lists VMs by VMID. A VM being cloned is listed as F8
// saw it: no name, no tags, lock clone and status unknown.
func (f *fakeAPI) ClusterResources(ctx context.Context) ([]vmResource, error) {
if err := f.enter(ctx, "ClusterResources"); err != nil {
return nil, err
}
f.mu.Lock()
defer f.mu.Unlock()
out := make([]vmResource, 0, len(f.vms))
for _, vmid := range slices.Sorted(maps.Keys(f.vms)) {
vm := f.vms[vmid]
r := vmResource{VMID: vmid, Name: vm.name, Node: vm.node, Type: "qemu", Status: vm.status, Lock: vm.lock, Tags: vm.config["tags"]}
if vm.kind != "" {
r.Type = vm.kind
}
if vm.template {
r.Template = 1
}
if vm.lock == "clone" {
r.Name, r.Tags, r.Status = "", "", "unknown"
}
out = append(out, r)
}
return out, nil
}
func (f *fakeAPI) VMConfig(ctx context.Context, node string, vmid int) (vmConfig, error) {
if err := f.enter(ctx, "VMConfig"); err != nil {
return nil, err
}
f.mu.Lock()
defer f.mu.Unlock()
vm, err := f.vm(node, vmid)
if err != nil {
return nil, err
}
cfg := vmConfig(maps.Clone(vm.config))
if vm.name != "" {
cfg["name"] = vm.name
}
if vm.lock != "" {
cfg["lock"] = vm.lock
}
if vm.template {
cfg["template"] = "1"
}
return cfg, nil
}
// SetVMConfig is refused while the VM is locked (F7). "delete" removes a
// comma-separated list of keys; "name" renames.
func (f *fakeAPI) SetVMConfig(ctx context.Context, node string, vmid int, params map[string]string) error {
if err := f.enter(ctx, "SetVMConfig"); err != nil {
return err
}
f.mu.Lock()
defer f.mu.Unlock()
vm, err := f.vm(node, vmid)
if err != nil {
return err
}
if vm.lock != "" {
return errLocked(vm.lock)
}
for k, v := range params {
switch k {
case "delete":
for key := range strings.SplitSeq(v, ",") {
delete(vm.config, key)
}
case "name":
vm.name = v
default:
vm.config[k] = v
}
}
return nil
}
// Clone creates the VM at once, locked and nameless in the listing, and a
// task that clears the lock, or removes the VM when cloneFailsWith is set.
// A taken newid fails synchronously (F4).
func (f *fakeAPI) Clone(ctx context.Context, node string, vmid int, p cloneParams) (string, error) {
if err := f.enter(ctx, "Clone"); err != nil {
return "", err
}
f.mu.Lock()
defer f.mu.Unlock()
src, err := f.vm(node, vmid)
if err != nil {
return "", err
}
if _, taken := f.vms[p.NewID]; taken {
return "", errNewIDInUse(p.NewID)
}
target := node
if p.Target != "" {
target = p.Target
}
cfg := maps.Clone(src.config)
for k, v := range cfg {
cfg[k] = strings.ReplaceAll(v, fmt.Sprintf("base-%d-", vmid), fmt.Sprintf("vm-%d-", p.NewID))
}
if p.Description != "" {
cfg["description"] = p.Description
}
vm := &fakeVM{node: target, name: p.Name, status: "stopped", qmpstatus: "stopped", lock: "clone", config: cfg}
f.vms[p.NewID] = vm
exit := "OK"
if f.cloneFailsWith != "" {
exit = f.cloneFailsWith
}
leaveLock := f.cloneLeavesLock
return f.newTask(node, "qmclone", strconv.Itoa(vmid), exit, func() {
switch {
case exit != "OK":
if f.vms[p.NewID] == vm {
delete(f.vms, p.NewID)
}
case !leaveLock:
vm.lock = ""
}
}), nil
}
// Resize runs as a task (F20): the same size is a no-op, a smaller one
// fails inside the task with the captured exitstatus.
func (f *fakeAPI) Resize(ctx context.Context, node string, vmid int, disk, size string) (string, error) {
if err := f.enter(ctx, "Resize"); err != nil {
return "", err
}
f.mu.Lock()
defer f.mu.Unlock()
vm, err := f.vm(node, vmid)
if err != nil {
return "", err
}
if vm.lock != "" {
return "", errLocked(vm.lock)
}
cur, ok := vm.config[disk]
if !ok {
return "", fakeErr(500, "fake: disk '"+disk+"' does not exist")
}
curBytes, ok := fakeParseSize(diskSizeOption(cur))
if !ok {
return "", fakeErr(500, "fake: disk '"+disk+"' has no size")
}
want, ok := fakeParseSize(strings.TrimPrefix(size, "+"))
if !ok {
return "", fakeErr(400, "fake: bad size '"+size+"'")
}
if strings.HasPrefix(size, "+") {
want += curBytes
}
exit := "OK"
if want < curBytes {
exit = "shrinking disks is not supported"
}
return f.newTask(node, "resize", strconv.Itoa(vmid), exit, func() {
if exit == "OK" {
vm.config[disk] = strings.Replace(cur, "size="+diskSizeOption(cur), "size="+fakeFormatSize(want), 1)
}
}), nil
}
// Start is accepted even while a clone holds the lock (F3).
func (f *fakeAPI) Start(ctx context.Context, node string, vmid int) (string, error) {
if err := f.enter(ctx, "Start"); err != nil {
return "", err
}
f.mu.Lock()
defer f.mu.Unlock()
vm, err := f.vm(node, vmid)
if err != nil {
return "", err
}
exit := "OK"
if f.startFailsWith != "" {
exit = f.startFailsWith
}
return f.newTask(node, "qmstart", strconv.Itoa(vmid), exit, func() {
if exit == "OK" {
vm.status, vm.qmpstatus = "running", "running"
}
}), nil
}
func (f *fakeAPI) Stop(ctx context.Context, node string, vmid int) (string, error) {
if err := f.enter(ctx, "Stop"); err != nil {
return "", err
}
f.mu.Lock()
defer f.mu.Unlock()
vm, err := f.vm(node, vmid)
if err != nil {
return "", err
}
return f.newTask(node, "qmstop", strconv.Itoa(vmid), "OK", func() {
vm.status, vm.qmpstatus = "stopped", "stopped"
}), nil
}
func (f *fakeAPI) VMStatus(ctx context.Context, node string, vmid int) (vmStatus, error) {
if err := f.enter(ctx, "VMStatus"); err != nil {
return vmStatus{}, err
}
f.mu.Lock()
defer f.mu.Unlock()
vm, err := f.vm(node, vmid)
if err != nil {
return vmStatus{}, err
}
return vmStatus{Status: vm.status, QMPStatus: vm.qmpstatus, Name: vm.name, Lock: vm.lock}, nil
}
// Destroy refuses a locked or running VM and removes it when its task
// ends.
func (f *fakeAPI) Destroy(ctx context.Context, node string, vmid int) (string, error) {
if err := f.enter(ctx, "Destroy"); err != nil {
return "", err
}
f.mu.Lock()
defer f.mu.Unlock()
vm, err := f.vm(node, vmid)
if err != nil {
return "", err
}
if vm.lock != "" {
return "", errLocked(vm.lock)
}
if vm.status == "running" {
return "", fakeErr(500, fmt.Sprintf("fake: VM %d is running - destroy failed", vmid))
}
exit := "OK"
if f.destroyFailsWith != "" {
exit = f.destroyFailsWith
}
return f.newTask(node, "qmdestroy", strconv.Itoa(vmid), exit, func() {
if exit == "OK" && f.vms[vmid] == vm {
delete(f.vms, vmid)
}
}), nil
}
// TaskStatus answers running for f.taskPolls polls, then stopped with the
// task's exitstatus, running its effect once.
func (f *fakeAPI) TaskStatus(ctx context.Context, node, upid string) (taskStatus, error) {
if err := f.enter(ctx, "TaskStatus"); err != nil {
return taskStatus{}, err
}
f.mu.Lock()
defer f.mu.Unlock()
t, ok := f.tasks[upid]
if !ok || t.node != node {
return taskStatus{}, fakeErr(500, "fake: no such task '"+upid+"'")
}
t.polls++
s := taskStatus{UPID: upid, Type: t.kind, ID: t.id, Node: t.node, Status: "running"}
if !t.done && t.polls > f.taskPolls {
t.done = true
if t.onDone != nil {
t.onDone()
}
}
if t.done {
s.Status, s.ExitStatus = "stopped", t.exit
}
return s, nil
}
// AgentNetworkInterfaces answers with the three messages of F5 in the
// order Proxmox checks them.
func (f *fakeAPI) AgentNetworkInterfaces(ctx context.Context, node string, vmid int) ([]agentInterface, error) {
if err := f.enter(ctx, "AgentNetworkInterfaces"); err != nil {
return nil, err
}
f.mu.Lock()
defer f.mu.Unlock()
vm, err := f.vm(node, vmid)
if err != nil {
return nil, err
}
if agent := vm.config["agent"]; agent != "1" && !strings.HasPrefix(agent, "1,") && !strings.Contains(agent, "enabled=1") {
return nil, fakeErr(500, msgAgentNotConfigured)
}
if vm.status != "running" {
return nil, errVMNotRunning(vmid)
}
if f.agentNotRunning {
return nil, fakeErr(500, msgAgentNotRunning)
}
return slices.Clone(vm.ifaces), nil
}
// UploadISO records the bytes and overwrites an existing file silently
// (F12). A body that disagrees with size fails, as the real Content-Length
// would.
func (f *fakeAPI) UploadISO(ctx context.Context, node, storage, filename string, r io.Reader, size int64) (string, error) {
if err := f.enter(ctx, "UploadISO"); err != nil {
return "", err
}
var content []byte
if r != nil {
var err error
if content, err = io.ReadAll(r); err != nil {
return "", err
}
}
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.storages[storage]; !ok {
return "", fakeErr(500, "fake: storage '"+storage+"' does not exist")
}
if int64(len(content)) != size {
return "", fakeErr(400, fmt.Sprintf("fake: body has %d bytes, size says %d", len(content), size))
}
if f.isos[storage] == nil {
f.isos[storage] = make(map[string][]byte)
}
f.isos[storage][filename] = content
return f.newTask(node, "imgcopy", "", "OK", nil), nil
}
// DeleteVolume is a task that ends OK whether or not the volume exists
// (F13).
func (f *fakeAPI) DeleteVolume(ctx context.Context, node, storage, volid string) (string, error) {
if err := f.enter(ctx, "DeleteVolume"); err != nil {
return "", err
}
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.storages[storage]; !ok {
return "", fakeErr(500, "fake: storage '"+storage+"' does not exist")
}
filename := strings.TrimPrefix(volid, storage+":iso/")
return f.newTask(node, "imgdel", storage, "OK", func() {
delete(f.isos[storage], filename)
}), nil
}
// diskSizeOption returns the size= option of a disk value.
func diskSizeOption(disk string) string {
for opt := range strings.SplitSeq(disk, ",") {
if v, ok := strings.CutPrefix(opt, "size="); ok {
return v
}
}
return ""
}
// fakeParseSize reads Proxmox's <n>[KMGT] sizes into bytes.
func fakeParseSize(s string) (int64, bool) {
if s == "" {
return 0, false
}
shift := map[byte]uint{'K': 10, 'M': 20, 'G': 30, 'T': 40}
unit, ok := shift[s[len(s)-1]]
if ok {
s = s[:len(s)-1]
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil || n < 0 {
return 0, false
}
return n << unit, true
}
func fakeFormatSize(b int64) string {
if b%(1<<30) == 0 {
return strconv.FormatInt(b>>30, 10) + "G"
}
return strconv.FormatInt(b>>20, 10) + "M"
}
// TestFakeAPI_errorsMatchCaptures keeps the fake's error texts equal to
// what Proxmox sent, so the provider's matchers are exercised against real
// wording in every test that goes through the fake.
func TestFakeAPI_errorsMatchCaptures(t *testing.T) {
t.Parallel()
tests := []struct {
capture string
fake *apiError
matcher func(error) bool
}{
{capture: "err_vm_missing", fake: errVMMissing("pv1-1", 999999), matcher: isNotExist},
{capture: "err_destroy_missing", fake: errVMMissing("pv1-1", 199999), matcher: isNotExist},
{capture: "err_config_while_clone_locked", fake: errLocked("clone"), matcher: isLocked},
{capture: "err_newid_in_use", fake: errNewIDInUse(199999), matcher: isAlreadyExists},
{capture: "err_agent_vm_stopped_agent_enabled", fake: errVMNotRunning(199999), matcher: isVMNotRunning},
{capture: "err_agent_not_running", fake: fakeErr(500, msgAgentNotRunning), matcher: isAgentNotRunning},
{capture: "err_agent_vm_stopped", fake: fakeErr(500, msgAgentNotConfigured), matcher: isAgentNotConfigured},
}
for _, tc := range tests {
t.Run(tc.capture, func(t *testing.T) {
t.Parallel()
want := loadAPIError(t, tc.capture)
if tc.fake.Status != want.Status || tc.fake.Message != want.Message {
t.Errorf("fake error = %d %q, capture = %d %q", tc.fake.Status, tc.fake.Message, want.Status, want.Message)
}
if !tc.matcher(tc.fake) {
t.Errorf("matcher does not accept the fake's %q", tc.fake.Message)
}
})
}
}
// pollTask polls a fake task until it stops and returns its exitstatus.
func pollTask(t *testing.T, f *fakeAPI, upid string) string {
t.Helper()
for range 10 {
s, err := f.TaskStatus(context.Background(), fakeNode, upid)
if err != nil {
t.Fatalf("TaskStatus(%s) error = %v", upid, err)
}
if s.Status == "stopped" {
return s.ExitStatus
}
}
t.Fatalf("task %s still running after 10 polls", upid)
return ""
}
func resourceByID(t *testing.T, f *fakeAPI, vmid int) (vmResource, bool) {
t.Helper()
rs, err := f.ClusterResources(context.Background())
if err != nil {
t.Fatalf("ClusterResources error = %v", err)
}
for _, r := range rs {
if r.VMID == vmid {
return r, true
}
}
return vmResource{}, false
}
func TestFakeAPI_cloneLocksUntilTaskEnds(t *testing.T) {
t.Parallel()
ctx := context.Background()
f := newFakeAPI()
f.addTemplate(9001, "debian-13-template")
f.taskPolls = 1
upid, err := f.Clone(ctx, fakeNode, 9001, cloneParams{NewID: 150042, Name: "proxy-x", Description: "d"})
if err != nil {
t.Fatalf("Clone error = %v", err)
}
if r, _ := resourceByID(t, f, 150042); r.Name != "" || r.Lock != "clone" || r.Status != "unknown" {
t.Errorf("mid-clone entry = %+v, want nameless, lock clone, status unknown (F8)", r)
}
if err := f.SetVMConfig(ctx, fakeNode, 150042, map[string]string{"cores": "1"}); !isLocked(err) {
t.Errorf("SetVMConfig mid-clone error = %v, want the lock answer (F7)", err)
}
if _, err := f.Clone(ctx, fakeNode, 9001, cloneParams{NewID: 150042, Name: "proxy-y"}); !isAlreadyExists(err) {
t.Errorf("second Clone onto 150042 error = %v, want config file already exists (F4)", err)
}
if s, _ := f.TaskStatus(ctx, fakeNode, upid); s.Status != "running" {
t.Errorf("first poll = %+v, want running with taskPolls 1", s)
}
if exit := pollTask(t, f, upid); exit != "OK" {
t.Errorf("clone exitstatus = %q, want OK", exit)
}
r, _ := resourceByID(t, f, 150042)
if r.Name != "proxy-x" || r.Lock != "" || r.Status != "stopped" || r.Template != 0 {
t.Errorf("cloned entry = %+v, want named, unlocked, stopped", r)
}
cfg, err := f.VMConfig(ctx, fakeNode, 150042)
if err != nil || cfg["description"] != "d" || cfg["scsi0"] != "local-lvm:vm-150042-disk-1,size=3G" || cfg["template"] != "" {
t.Errorf("clone config = %v, %v", cfg, err)
}
}
func TestFakeAPI_cloneFailureAndLeftLock(t *testing.T) {
t.Parallel()
ctx := context.Background()
t.Run("failed clone removes the VM", func(t *testing.T) {
t.Parallel()
f := newFakeAPI()
f.addTemplate(9001, "debian-13-template")
f.cloneFailsWith = "clone failed: out of space"
upid, _ := f.Clone(ctx, fakeNode, 9001, cloneParams{NewID: 150042, Name: "proxy-x"})
if exit := pollTask(t, f, upid); exit != "clone failed: out of space" {
t.Errorf("exitstatus = %q", exit)
}
if _, ok := resourceByID(t, f, 150042); ok {
t.Error("failed clone left its VM")
}
})
t.Run("interrupted clone keeps the lock", func(t *testing.T) {
t.Parallel()
f := newFakeAPI()
f.addTemplate(9001, "debian-13-template")
f.cloneLeavesLock = true
upid, _ := f.Clone(ctx, fakeNode, 9001, cloneParams{NewID: 150042, Name: "proxy-x"})
pollTask(t, f, upid)
if r, _ := resourceByID(t, f, 150042); r.Lock != "clone" {
t.Errorf("entry = %+v, want lock clone kept", r)
}
})
}
func TestFakeAPI_powerAgentAndDestroy(t *testing.T) {
t.Parallel()
ctx := context.Background()
f := newFakeAPI()
vm := f.addVM(150042, "proxy-x", "stopped")
vm.ifaces = []agentInterface{{Name: "ens18", IPAddresses: []agentIP{{Address: "192.168.4.147", Type: "ipv4", Prefix: 24}}}}
if _, err := f.AgentNetworkInterfaces(ctx, fakeNode, 150042); !isVMNotRunning(err) {
t.Errorf("agent on a stopped VM error = %v, want VM is not running", err)
}
upid, _ := f.Start(ctx, fakeNode, 150042)
pollTask(t, f, upid)
if s, _ := f.VMStatus(ctx, fakeNode, 150042); s.Status != "running" || s.QMPStatus != "running" {
t.Errorf("status after start = %+v", s)
}
f.agentNotRunning = true
if _, err := f.AgentNetworkInterfaces(ctx, fakeNode, 150042); !isAgentNotRunning(err) {
t.Errorf("agent error = %v, want agent not running", err)
}
f.agentNotRunning = false
if ifaces, err := f.AgentNetworkInterfaces(ctx, fakeNode, 150042); err != nil || firstIPv4(ifaces) != "192.168.4.147" {
t.Errorf("agent = %v, %v", ifaces, err)
}
if _, err := f.Destroy(ctx, fakeNode, 150042); err == nil {
t.Error("Destroy of a running VM succeeded")
}
upid, _ = f.Stop(ctx, fakeNode, 150042)
pollTask(t, f, upid)
upid, err := f.Destroy(ctx, fakeNode, 150042)
if err != nil {
t.Fatalf("Destroy error = %v", err)
}
pollTask(t, f, upid)
if _, err := f.VMConfig(ctx, fakeNode, 150042); !isNotExist(err) {
t.Errorf("VMConfig after destroy error = %v, want does not exist", err)
}
if _, err := f.VMStatus(ctx, "pv1-2", 150042); !isNotExist(err) {
t.Errorf("lookup on another node error = %v, want does not exist", err)
}
}
func TestFakeAPI_resizeIsATask(t *testing.T) {
t.Parallel()
tests := []struct {
name string
size string
wantExit string
wantDisk string
}{
{name: "grow absolute", size: "8G", wantExit: "OK", wantDisk: "local-lvm:vm-150042-disk-1,size=8G"},
{name: "grow relative", size: "+1G", wantExit: "OK", wantDisk: "local-lvm:vm-150042-disk-1,size=4G"},
{name: "same size", size: "3G", wantExit: "OK", wantDisk: "local-lvm:vm-150042-disk-1,size=3G"},
{name: "shrink fails in the task", size: "2G", wantExit: loadTaskExit(t, "task_status_stopped_error"), wantDisk: "local-lvm:vm-150042-disk-1,size=3G"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
f := newFakeAPI()
f.addVM(150042, "proxy-x", "stopped")
upid, err := f.Resize(context.Background(), fakeNode, 150042, "scsi0", tc.size)
if err != nil {
t.Fatalf("Resize error = %v", err)
}
if exit := pollTask(t, f, upid); exit != tc.wantExit {
t.Errorf("exitstatus = %q, want %q", exit, tc.wantExit)
}
if vm, _ := f.vmSnapshot(150042); vm.config["scsi0"] != tc.wantDisk {
t.Errorf("scsi0 = %q, want %q", vm.config["scsi0"], tc.wantDisk)
}
})
}
}
func loadTaskExit(t *testing.T, name string) string {
t.Helper()
return decodeData[taskStatus](t, name).ExitStatus
}
func TestFakeAPI_uploadOverwritesAndDeleteIsIdempotent(t *testing.T) {
t.Parallel()
ctx := context.Background()
f := newFakeAPI()
for _, body := range []string{"first", "second"} {
upid, err := f.UploadISO(ctx, fakeNode, fakeISOStorage, "proxy-x-seed.iso", strings.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("UploadISO error = %v", err)
}
pollTask(t, f, upid)
}
if b, _ := f.iso(fakeISOStorage, "proxy-x-seed.iso"); string(b) != "second" {
t.Errorf("iso = %q, want the second upload (F12 overwrite)", b)
}
if _, err := f.UploadISO(ctx, fakeNode, fakeISOStorage, "x.iso", strings.NewReader("abc"), 4); err == nil {
t.Error("UploadISO with a wrong size succeeded")
}
for range 2 {
upid, err := f.DeleteVolume(ctx, fakeNode, fakeISOStorage, seedVolID(fakeISOStorage, "proxy-x"))
if err != nil {
t.Fatalf("DeleteVolume error = %v", err)
}
if exit := pollTask(t, f, upid); exit != "OK" {
t.Errorf("delete exitstatus = %q, want OK even when absent (F13)", exit)
}
}
if _, ok := f.iso(fakeISOStorage, "proxy-x-seed.iso"); ok {
t.Error("iso still present after delete")
}
}
func TestFakeAPI_hooksBlocksAndLog(t *testing.T) {
t.Parallel()
f := newFakeAPI()
boom := errors.New("boom")
f.failWith("Version", boom)
if _, err := f.Version(context.Background()); !errors.Is(err, boom) {
t.Errorf("Version error = %v, want the hook's", err)
}
f.blockOn("ClusterResources")
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := f.ClusterResources(ctx); !errors.Is(err, context.Canceled) {
t.Errorf("blocked call with ended ctx error = %v, want context.Canceled", err)
}
if got := f.callLog(); !slices.Equal(got, []string{"Version", "ClusterResources"}) {
t.Errorf("call log = %v", got)
}
if f.callCount("Version") != 1 {
t.Errorf("Version count = %d, want 1", f.callCount("Version"))
}
}