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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user