Files
egress-proxies-operator/internal/provider/proxmox/proxmox_test.go
T
kacerrandClaude Opus 5.5 6e9ef26dcb proxmox: contract and observability tests, integration test, testing docs
The contract sweep in provider_test.go: every method and Check returns
within a second of a cancel, every wrapErr path carries the configured
name, op and id, the V0/V1/V2 logging tiers and the no-op lines, and a
Get over the real client whose HTTP spans are children of provider.get.
proxmox_integration_test.go (-tags=integration) holds TestProxmox_lifecycle
and TestProxmoxCheck_realHost; both compile and skip without
PROXMOX_TEST_*, and have not run against a host yet. make
test-integration-proxmox runs them. docs/testing.md gains the proxmox,
nocloud and keyedlock rows, the Live Proxmox gaps and the integration
section; spec entry 21's section 11 list is ticked.

Waits that could hang the package under a mutation are bounded: the
concurrent-Create loop and eight direct blocking calls in older tests.

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

238 lines
8.0 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)
}
}
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()
within(t, 5*time.Second, func() { _, 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)
}
}