Files
egress-proxies-operator/internal/provider/proxmox/fake_test.go
T
kacerrandClaude Opus 5.5 e05d994974 proxmox: thin HTTP client, seam, log decorator and in-memory fake
httpAPI implements the sixteen seam methods over net/http: the token is
read per request, non-2xx answers become apiError from the body message or
the reason phrase, echoed secrets are redacted, the CA loads lazily, HTTP/2
is off, and uploads stream with an exact Content-Length. The observed
decorator logs one V(1) line per call and starts no span; New wires both.
fakeAPI simulates clone locks, tasks and ISOs with the captured error texts.

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

894 lines
26 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 and startFailsWith, when set, are the exitstatus of
// every clone or start task. A failed clone removes its VM.
cloneFailsWith, startFailsWith 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
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.mu.Lock()
defer f.mu.Unlock()
f.hooks[method] = func() error { return err }
}
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 {
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.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))
}
return f.newTask(node, "qmdestroy", strconv.Itoa(vmid), "OK", func() {
if 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"))
}
}