Files
egress-proxies-operator/internal/provider/proxmox/errors_test.go
T
kacerrandClaude Opus 5.5 3d72507894 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>
2026-09-24 10:28:28 +00:00

204 lines
10 KiB
Go

package proxmox
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"net"
"net/url"
"strings"
"testing"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
func TestClassify_table(t *testing.T) {
t.Parallel()
fixture := func(name string) error { return loadAPIError(t, name) }
tests := []struct {
name string
op string
err error
want error
}{
{name: "nil is transient", op: "get", err: nil, want: provider.ErrTransient},
{name: "missing VM config on get", op: "get", err: fixture("err_vm_missing"), want: provider.ErrNotFound},
{name: "missing VM status on get", op: "get", err: fixture("err_vm_missing_status"), want: provider.ErrNotFound},
{name: "missing VM on delete", op: "delete", err: fixture("err_destroy_missing"), want: provider.ErrNotFound},
{name: "missing VM on list", op: "list", err: fixture("err_vm_missing"), want: provider.ErrNotFound},
{name: "missing VM inside create is transient", op: "create", err: fixture("err_vm_missing"), want: provider.ErrTransient},
{name: "wrapped missing VM keeps its class", op: "get", err: fmt.Errorf("reading config: %w", fixture("err_vm_missing")), want: provider.ErrNotFound},
{name: "locked by a clone", op: "create", err: fixture("err_config_while_clone_locked"), want: provider.ErrTransient},
{name: "VMID taken escaping the allocator", op: "create", err: fixture("err_newid_in_use"), want: provider.ErrTransient},
{name: "agent not running escaping Get", op: "get", err: fixture("err_agent_not_running"), want: provider.ErrTransient},
{name: "agent not configured escaping Get", op: "get", err: fixture("err_agent_vm_stopped"), want: provider.ErrTransient},
{name: "VM not running escaping Get", op: "get", err: fixture("err_agent_vm_stopped_agent_enabled"), want: provider.ErrTransient},
{name: "wrong secret", op: "get", err: fixture("err_unauthorized"), want: provider.ErrPermanent},
{name: "unknown token id", op: "list", err: fixture("err_unauthorized_unknown_token"), want: provider.ErrPermanent},
{name: "no credentials", op: "list", err: fixture("err_no_credentials"), want: provider.ErrPermanent},
{name: "missing privilege", op: "get", err: fixture("err_forbidden"), want: provider.ErrPermanent},
{name: "missing Datastore.Allocate", op: "delete", err: fixture("err_forbidden_volume_delete"), want: provider.ErrPermanent},
{name: "parameter verification", op: "create", err: fixture("err_bad_param"), want: provider.ErrPermanent},
{name: "invalid tag", op: "create", err: fixture("err_tag_invalid"), want: provider.ErrPermanent},
{name: "upload without .iso", op: "create", err: fixture("err_upload_bad_name"), want: provider.ErrPermanent},
{name: "clone onto a storage without images", op: "create", err: fixture("err_clone_bad_storage"), want: provider.ErrPermanent},
{name: "any other 4xx", op: "get", err: &apiError{Status: 404, Message: "Not Found"}, want: provider.ErrPermanent},
{name: "other 500", op: "get", err: &apiError{Status: 500, Message: "got timeout"}, want: provider.ErrTransient},
{name: "storage does not exist is not a missing VM", op: "delete", err: &apiError{Status: 500, Message: "storage 'x' does not exist"}, want: provider.ErrTransient},
{name: "502", op: "get", err: &apiError{Status: 502, Message: "Bad Gateway"}, want: provider.ErrTransient},
{name: "pveproxy 595", op: "get", err: &apiError{Status: 595, Message: "No route to host"}, want: provider.ErrTransient},
{name: "pveproxy 596", op: "list", err: &apiError{Status: 596, Message: "Connection timed out"}, want: provider.ErrTransient},
{name: "unknown authority", op: "get", err: x509.UnknownAuthorityError{}, want: provider.ErrPermanent},
{name: "hostname mismatch", op: "create", err: x509.HostnameError{Certificate: &x509.Certificate{}, Host: "192.168.2.8"}, want: provider.ErrPermanent},
{name: "expired certificate", op: "list", err: x509.CertificateInvalidError{Reason: x509.Expired}, want: provider.ErrPermanent},
{name: "tls verification wrapped in url.Error", op: "get", err: &url.Error{Op: "Get", URL: "https://pve", Err: &tls.CertificateVerificationError{Err: x509.UnknownAuthorityError{}}}, want: provider.ErrPermanent},
{name: "context canceled", op: "get", err: context.Canceled, want: provider.ErrTransient},
{name: "context deadline", op: "get", err: context.DeadlineExceeded, want: provider.ErrTransient},
{name: "net.OpError", op: "get", err: &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")}, want: provider.ErrTransient},
{name: "io.EOF", op: "list", err: io.EOF, want: provider.ErrTransient},
{name: "io.ErrUnexpectedEOF", op: "list", err: io.ErrUnexpectedEOF, want: provider.ErrTransient},
{name: "anything else is transient", op: "get", err: errors.New("anything"), want: provider.ErrTransient},
{name: "pre-classified NotFound stays", op: "create", err: fmt.Errorf("x: %w", provider.ErrNotFound), want: provider.ErrNotFound},
{name: "pre-classified Quota stays", op: "create", err: fmt.Errorf("x: %w", provider.ErrQuotaExceeded), want: provider.ErrQuotaExceeded},
{name: "pre-classified Permanent stays", op: "get", err: permanentf("bad %s", "thing"), want: provider.ErrPermanent},
{name: "pre-classified Transient beats a 4xx underneath", op: "get", err: fmt.Errorf("%w: %w", provider.ErrTransient, fixture("err_forbidden")), want: provider.ErrTransient},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := classify(tc.op, tc.err); !errors.Is(got, tc.want) {
t.Errorf("classify(%q, %v) = %v, want %v", tc.op, tc.err, got, tc.want)
}
})
}
}
// TestAPIError_matchers runs every matcher over every captured error, so a
// matcher that also fires on another message fails here.
func TestAPIError_matchers(t *testing.T) {
t.Parallel()
matchers := map[string]func(error) bool{
"isNotExist": isNotExist,
"isLocked": isLocked,
"isAlreadyExists": isAlreadyExists,
"isAgentNotRunning": isAgentNotRunning,
"isAgentNotConfigured": isAgentNotConfigured,
"isNoSuchStorage": isNoSuchStorage,
"isVMNotRunning": isVMNotRunning,
}
tests := []struct {
capture string
want string // the one matcher that fires, or "" for none
}{
{"err_vm_missing", "isNotExist"},
{"err_vm_missing_status", "isNotExist"},
{"err_destroy_missing", "isNotExist"},
{"err_config_while_clone_locked", "isLocked"},
{"err_newid_in_use", "isAlreadyExists"},
{"storage_status_missing", "isNoSuchStorage"},
{"err_agent_not_running", "isAgentNotRunning"},
{"err_agent_vm_stopped", "isAgentNotConfigured"},
{"err_agent_vm_stopped_agent_enabled", "isVMNotRunning"},
{"err_unauthorized", ""},
{"err_forbidden", ""},
{"err_bad_param", ""},
{"err_clone_bad_storage", ""},
}
for _, tc := range tests {
t.Run(tc.capture, func(t *testing.T) {
t.Parallel()
err := fmt.Errorf("wrapped: %w", loadAPIError(t, tc.capture))
for name, match := range matchers {
if got, want := match(err), name == tc.want; got != want {
t.Errorf("%s(%v) = %v, want %v", name, err, got, want)
}
}
})
}
if isNotExist(errors.New("Configuration file 'x' does not exist")) {
t.Error("isNotExist matched an error that is not an apiError")
}
}
func TestNewAPIError_fromCaptures(t *testing.T) {
t.Parallel()
tests := []struct {
capture string
wantStatus int
wantMessage string
wantErrors map[string]string
}{
{capture: "err_vm_missing", wantStatus: 500, wantMessage: "Configuration file 'nodes/pv1-1/qemu-server/999999.conf' does not exist"},
{capture: "err_unauthorized", wantStatus: 401, wantMessage: "Authentication failed!"},
{capture: "err_no_credentials", wantStatus: 401, wantMessage: "No ticket"},
{capture: "err_forbidden", wantStatus: 403, wantMessage: "Permission check failed (/nodes/pv1-1, Sys.Syslog)"},
{capture: "err_bad_param", wantStatus: 400, wantMessage: "Parameter verification failed.", wantErrors: map[string]string{"newid": "type check ('integer') failed - got 'abc'"}},
}
for _, tc := range tests {
t.Run(tc.capture, func(t *testing.T) {
t.Parallel()
got := loadAPIError(t, tc.capture)
if got.Status != tc.wantStatus || got.Message != tc.wantMessage {
t.Errorf("newAPIError = {%d %q}, want {%d %q}", got.Status, got.Message, tc.wantStatus, tc.wantMessage)
}
if len(got.Errors) != len(tc.wantErrors) {
t.Fatalf("Errors = %v, want %v", got.Errors, tc.wantErrors)
}
for k, v := range tc.wantErrors {
if got.Errors[k] != v {
t.Errorf("Errors[%q] = %q, want %q", k, got.Errors[k], v)
}
}
})
}
}
func TestNewAPIError_fallbacks(t *testing.T) {
t.Parallel()
if got := newAPIError(503, "", []byte("<html>")); got.Message != "Service Unavailable" {
t.Errorf("no reason and no JSON body: Message = %q, want the status text", got.Message)
}
if got := newAPIError(500, "reason text", []byte(`{"data":null}`)); got.Message != "reason text" {
t.Errorf("body without message: Message = %q, want the reason phrase", got.Message)
}
}
func TestAPIError_errorString(t *testing.T) {
t.Parallel()
got := loadAPIError(t, "err_bad_param").Error()
want := "proxmox API 400: Parameter verification failed. (newid: type check ('integer') failed - got 'abc')"
if got != want {
t.Errorf("Error() = %q, want %q", got, want)
}
e := &apiError{Status: 400, Message: "m", Errors: map[string]string{"b": "2", "a": "1"}}
if got := e.Error(); !strings.HasSuffix(got, "(a: 1; b: 2)") {
t.Errorf("Error() = %q, want field errors sorted by key", got)
}
}
func TestPermanentf_wrapsClassWithMessage(t *testing.T) {
t.Parallel()
perm := permanentf("node %q missing", "x")
if !errors.Is(perm, provider.ErrPermanent) || perm.Error() != `provider: permanent error: node "x" missing` {
t.Errorf("permanentf() = %q, want ErrPermanent with the message", perm)
}
}
func TestTransientf_wrapsClassWithMessage(t *testing.T) {
t.Parallel()
trans := transientf("retry %d", 3)
if !errors.Is(trans, provider.ErrTransient) || trans.Error() != "provider: transient error: retry 3" {
t.Errorf("transientf() = %q, want ErrTransient with the message", trans)
}
}