package proxmox import ( "context" "errors" "fmt" "io" "maps" "regexp" "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 } // clearHooks drops every hook, so the faults they injected stop. func (f *fakeAPI) clearHooks() { f.mu.Lock() defer f.mu.Unlock() clear(f.hooks) } 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)) } // errNoSuchStorage is the answer for a storage the cluster does not know // (Phase 10 capture storage_status_missing). func errNoSuchStorage() *apiError { return newAPIError(400, "", []byte(`{"message":"Parameter verification failed.\n","data":null,"errors":{"storage":"No such storage."}}`)) } func errLocked(lock string) *apiError { return fakeErr(500, "VM is locked ("+lock+")") } // isLocked and isVMNotRunning recognise two answers the provider leaves to // classify's Transient default. They live here only to hold the fake's // wording to the captures. The stopped-VM message is matched whole because // " is not running" is also a suffix of the agent-not-running answer. var vmNotRunning = regexp.MustCompile(`^VM [0-9]+ is not running$`) func isLocked(err error) bool { msg, ok := server500(err) return ok && strings.HasPrefix(msg, "VM is locked") } func isVMNotRunning(err error) bool { msg, ok := server500(err) return ok && vmNotRunning.MatchString(msg) } 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{}, errNoSuchStorage() } 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 } // A hibernated VM is destroyed like a stopped one (Phase 10 captures // destroy_hibernated_response and task_status_destroy_hibernated). if vm.lock != "" && vm.lock != lockSuspended { 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 [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: "storage_status_missing", fake: errNoSuchStorage(), matcher: isNoSuchStorage}, {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 || !maps.Equal(tc.fake.Errors, want.Errors) { t.Errorf("fake error = %d %q %v, capture = %d %q %v", tc.fake.Status, tc.fake.Message, tc.fake.Errors, want.Status, want.Message, want.Errors) } 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")) } }