Phase 10. Two review passes (go-operator-reviewer, /code-review high) over the branch, recorded in docs/reviews/2026-09-23-proxmox-provider-review/: 9 findings fixed, 3 accepted as documented limits, 7 rejected with a reason. - Delete removes the seed between the stop and the destroy, so a failed seed delete leaves the VM for the next Delete instead of leaking the ISO; a Permanent refusal on the every-node path fails the call. - Create skips a probe VMID whose listed name is someone else's without waiting on its lock. - The three answers Phase 0 never captured are fixtures now: a missing storage is a 400 (the Checker reports StorageNotFound), a hibernated VM is destroyable (Delete destroys ours), a paused VM stops. - TestProxmox_lifecycle and TestProxmoxCheck_realHost passed on pv1-1 (PVE 9.2.20); the docs record the run and drop "not yet run". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
944 lines
28 KiB
Go
944 lines
28 KiB
Go
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 <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: "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"))
|
|
}
|
|
}
|