Files
egress-proxies-operator/internal/provider/proxmox/proxmox_test.go
T
kacerrandClaude Opus 5.5 803716459a proxmox: Get, ListByTag and task polling
Get follows spec entry 9's check order (lock and unknown status before
the name, suspended is Stopped after the name and mark checks), reads
status/current so paused is Stopped, and maps the three agent messages
of entry 7. ListByTag reads one config per candidate and fails whole
only on the resources call or an ended ctx. waitTask polls the UPID's
node every budgets.poll, logs failed tasks at V(1) and counts
"WARNINGS: <n>" as success; waitUnlocked serves Create's ownership check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:11:20 +00:00

253 lines
8.6 KiB
Go

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)
}
}
// TestNew_wiresObservedHTTPClient checks that production calls go through
// the logging decorator to the HTTP client, with /api2/json appended and
// the request timeout from the config.
func TestNew_wiresObservedHTTPClient(t *testing.T) {
t.Parallel()
ctx, _ := captureContext()
cfg := testConfig()
cfg.RequestTimeoutSeconds = 45
p, err := New(ctx, provider.ProviderConfig{Name: "proxmox-home", Type: "proxmox", Proxmox: &cfg})
if err != nil {
t.Fatalf("New() error = %v", err)
}
o, ok := p.(*Provider).api.(*observed)
if !ok {
t.Fatalf("api = %T, want *observed", p.(*Provider).api)
}
h, ok := o.inner.(*httpAPI)
if !ok {
t.Fatalf("observed.inner = %T, want *httpAPI", o.inner)
}
if o.provider != "proxmox-home" || h.base.String() != "https://192.0.2.8:8006/api2/json" || h.timeout != 45*time.Second || h.tokenFile != cfg.TokenFile {
t.Errorf("wiring = provider %q base %q timeout %v token file %q", o.provider, h.base, h.timeout, h.tokenFile)
}
}
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 Create and Delete 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"})
deleteErr := p.Delete(ctx, "vms/150042/proxy-x")
for op, err := range map[string]error{"create": createErr, "delete": deleteErr} {
if !errors.Is(err, provider.ErrTransient) {
t.Errorf("%s error = %v, want ErrTransient", op, err)
}
}
}
func TestLockName_givesUpWithContextAsTransient(t *testing.T) {
t.Parallel()
p := newProvider("proxmox-home", testConfig(), nil)
release, err := p.lockName(context.Background(), "proxy-abc")
if err != nil {
t.Fatalf("lockName error = %v", err)
}
defer release()
other, err := p.lockName(context.Background(), "proxy-def")
if err != nil {
t.Fatalf("lockName on another name error = %v, want it free", err)
}
other()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
_, err = p.lockName(ctx, "proxy-abc")
if !errors.Is(err, provider.ErrTransient) || !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("lockName on a held name error = %v, want ErrTransient wrapping DeadlineExceeded", err)
}
}