proxmox: review fixes, real-host validation record, changelog

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>
This commit is contained in:
2026-09-24 10:28:28 +00:00
co-authored by Claude Opus 5.5
parent 7f2073f94b
commit 3d72507894
28 changed files with 882 additions and 201 deletions
+37 -4
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"maps"
"regexp"
"slices"
"strconv"
"strings"
@@ -140,6 +141,13 @@ func (f *fakeAPI) hookOn(method string, fn func() error) {
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()
@@ -191,8 +199,30 @@ 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))
}
@@ -272,7 +302,7 @@ func (f *fakeAPI) StorageStatus(ctx context.Context, node, storage string) (stor
defer f.mu.Unlock()
s, ok := f.storages[storage]
if !ok {
return storageStatus{}, fakeErr(500, "fake: storage '"+storage+"' does not exist")
return storageStatus{}, errNoSuchStorage()
}
return s, nil
}
@@ -505,7 +535,9 @@ func (f *fakeAPI) Destroy(ctx context.Context, node string, vmid int) (string, e
if err != nil {
return "", err
}
if vm.lock != "" {
// 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" {
@@ -666,6 +698,7 @@ func TestFakeAPI_errorsMatchCaptures(t *testing.T) {
{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},
@@ -674,8 +707,8 @@ func TestFakeAPI_errorsMatchCaptures(t *testing.T) {
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.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)