proxmox: config block, IDs, marks, state mapping, classify, registration

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>
This commit is contained in:
2026-09-23 09:42:46 +00:00
co-authored by Claude Opus 5.5
parent 4e974f30f6
commit 6e996ae2c7
19 changed files with 1856 additions and 4 deletions
+208
View File
@@ -0,0 +1,208 @@
package proxmox
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/go-logr/logr/funcr"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// captureContext collects funcr output at V(2).
func captureContext() (context.Context, *[]string) {
lines := &[]string{}
var mu sync.Mutex
log := funcr.New(func(prefix, args string) {
mu.Lock()
defer mu.Unlock()
*lines = append(*lines, prefix+" "+args)
}, funcr.Options{Verbosity: 2})
return logf.IntoContext(context.Background(), log), lines
}
func testConfig() provider.ProxmoxConfig {
return provider.ProxmoxConfig{
URL: "https://192.0.2.8:8006",
TokenFile: "/nonexistent/proxmox/token",
Nodes: []string{"pv1-1"},
ISOStorage: "proxy-seeds",
Templates: map[string]provider.ProxmoxTemplate{"debian-13": {VMID: 9001}},
MachineTypes: map[string]provider.ProxmoxMachineType{"small": {Cores: 1, MemoryMiB: 1024}},
}
}
func TestNew_doesNotDialAndWarnsOnMissingToken(t *testing.T) {
t.Parallel()
ctx, lines := captureContext()
cfg := testConfig()
start := time.Now()
p, err := New(ctx, provider.ProviderConfig{Name: "proxmox-home", Type: "proxmox", Proxmox: &cfg})
if err != nil || p == nil {
t.Fatalf("New() = %v, %v; want a provider", p, err)
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Errorf("New() took %v, want under 100ms (it must not make a request)", elapsed)
}
var warnings int
for _, l := range *lines {
if strings.Contains(l, "WARNING:") && strings.Contains(l, "token file") {
warnings++
}
if !strings.Contains(l, `"provider"="proxmox-home"`) {
t.Errorf("log line lacks the provider value: %s", l)
}
}
if warnings != 1 {
t.Errorf("got %d token WARNING: lines, want 1:\n%s", warnings, strings.Join(*lines, "\n"))
}
}
func TestNew_insecureSkipTLSVerifyWarns(t *testing.T) {
t.Parallel()
ctx, lines := captureContext()
cfg := testConfig()
cfg.InsecureSkipTLSVerify = true
if _, err := New(ctx, provider.ProviderConfig{Name: "proxmox-home", Type: "proxmox", Proxmox: &cfg}); err != nil {
t.Fatalf("New() error = %v, want nil", err)
}
for _, l := range *lines {
if strings.Contains(l, "WARNING:") && strings.Contains(l, "not verified") {
return
}
}
t.Errorf("no insecureSkipTLSVerify warning in:\n%s", strings.Join(*lines, "\n"))
}
func TestNew_nilBlockDoesNotPanic(t *testing.T) {
t.Parallel()
ctx, _ := captureContext()
p, err := New(ctx, provider.ProviderConfig{Name: "proxmox-empty", Type: "proxmox"})
if err != nil {
t.Fatalf("New() error = %v, want nil", err)
}
pp, ok := p.(*Provider)
if !ok {
t.Fatalf("New() = %T, want *Provider", p)
}
if pp.name != "proxmox-empty" || pp.cfg.VMIDRange == nil || pp.now == nil {
t.Errorf("provider = %+v, want its name, defaults and a clock", pp)
}
}
func TestWithDefaults_table(t *testing.T) {
t.Parallel()
off := false
tests := []struct {
name string
in provider.ProxmoxConfig
wantRange provider.ProxmoxVMIDRange
wantTimeout int
wantAgent bool
}{
{name: "empty fills every default", wantRange: provider.ProxmoxVMIDRange{Min: 100000, Max: 199999}, wantTimeout: 30, wantAgent: true},
{
name: "explicit values kept",
in: provider.ProxmoxConfig{VMIDRange: &provider.ProxmoxVMIDRange{Min: 500, Max: 600}, RequestTimeoutSeconds: 45, InstallGuestAgent: &off},
wantRange: provider.ProxmoxVMIDRange{Min: 500, Max: 600},
wantTimeout: 45,
wantAgent: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := withDefaults(tc.in)
if *got.VMIDRange != tc.wantRange || got.RequestTimeoutSeconds != tc.wantTimeout || *got.InstallGuestAgent != tc.wantAgent {
t.Errorf("withDefaults() = range %+v timeout %d agent %v; want %+v %d %v",
*got.VMIDRange, got.RequestTimeoutSeconds, *got.InstallGuestAgent, tc.wantRange, tc.wantTimeout, tc.wantAgent)
}
})
}
}
func TestWithDefaults_doesNotShareWithCaller(t *testing.T) {
t.Parallel()
in := testConfig()
in.VMIDRange = &provider.ProxmoxVMIDRange{Min: 500, Max: 600}
got := withDefaults(in)
got.Nodes[0] = "changed"
got.VMIDRange.Min = 1
got.Templates["debian-13"] = provider.ProxmoxTemplate{VMID: 1}
got.MachineTypes["small"] = provider.ProxmoxMachineType{}
if in.Nodes[0] != "pv1-1" || in.VMIDRange.Min != 500 || in.Templates["debian-13"].VMID != 9001 || in.MachineTypes["small"].Cores != 1 {
t.Errorf("mutating the result changed the caller's config: %+v", in)
}
}
func TestNode_table(t *testing.T) {
t.Parallel()
tests := []struct {
name string
nodes []string
zone string
want string
wantErr string
}{
{name: "one node and empty zone selects it", nodes: []string{"pv1-1"}, zone: "", want: "pv1-1"},
{name: "several nodes and empty zone is permanent", nodes: []string{"pv1-1", "pv1-2"}, zone: "", wantErr: "placement.zone is required"},
{name: "known node", nodes: []string{"pv1-1", "pv1-2"}, zone: "pv1-2", want: "pv1-2"},
{name: "unknown node is permanent", nodes: []string{"pv1-1"}, zone: "pv9", wantErr: `placement.zone "pv9" is not in proxmox.nodes`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
cfg := testConfig()
cfg.Nodes = tc.nodes
got, err := newProvider("proxmox-home", cfg, nil).node(tc.zone)
if tc.wantErr == "" {
if err != nil || got != tc.want {
t.Errorf("node(%q) = %q, %v; want %q, nil", tc.zone, got, err, tc.want)
}
return
}
if !errors.Is(err, provider.ErrPermanent) || !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("node(%q) error = %v, want ErrPermanent containing %q", tc.zone, err, tc.wantErr)
}
})
}
}
func TestWrapErr_carriesProviderNameOpAndID(t *testing.T) {
t.Parallel()
p := newProvider("proxmox-home", testConfig(), nil)
err := p.wrapErr("get", "vms/150042/proxy-x", loadAPIError(t, "err_vm_missing"))
var pe *provider.Error
if !errors.As(err, &pe) {
t.Fatalf("wrapErr() = %T, want *provider.Error", err)
}
if pe.Provider != "proxmox-home" || pe.Op != "get" || pe.ID != "vms/150042/proxy-x" || !errors.Is(err, provider.ErrNotFound) {
t.Errorf("wrapErr() = %+v, want configured name, op, id and class NotFound", pe)
}
var ae *apiError
if !errors.As(err, &ae) || ae.Status != 500 {
t.Errorf("wrapErr() lost the underlying apiError: %v", err)
}
}
// TestStubs_answerTransient keeps a Proxy on this provider retrying, not
// latched, until the methods are implemented in later phases.
func TestStubs_answerTransient(t *testing.T) {
t.Parallel()
ctx := context.Background()
p := newProvider("proxmox-home", testConfig(), nil)
_, createErr := p.Create(ctx, provider.CreateRequest{Name: "proxy-x"})
_, getErr := p.Get(ctx, "vms/150042/proxy-x")
deleteErr := p.Delete(ctx, "vms/150042/proxy-x")
_, listErr := p.ListByTag(ctx)
for op, err := range map[string]error{"create": createErr, "get": getErr, "delete": deleteErr, "list": listErr} {
if !errors.Is(err, provider.ErrTransient) {
t.Errorf("%s error = %v, want ErrTransient", op, err)
}
}
}