Phase 2 of the proxmox provider plan. Adds the proxmox: config block with its validation, registers the type in the config allow-list and the constructor map, and starts internal/provider/proxmox: the api seam and its data types, providerIDs (vms/<vmid>/<name>) with the UID-derived VMID, the description mark, state mapping, and error classification. Tests read the responses captured from pv1-1 in Phase 0 rather than repeating Proxmox's wording, and every matcher is run against every captured error so none fires on the wrong message. The contract methods are Transient stubs until Phases 5 to 7. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
198 lines
9.8 KiB
Go
198 lines
9.8 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,
|
|
"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"},
|
|
{"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 TestPermanentfAndTransientf(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)
|
|
}
|
|
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)
|
|
}
|
|
}
|