Files
egress-proxies-operator/internal/provider/proxmox/proxmox_test.go
T
kacerrandClaude Opus 5.5 34f132d421 proxmox: Create by cloning a template, seed upload, config, start
Create validates placement, then under the per-name lock adopts the VM a
previous attempt left (by name, or at a taken VMID after the ownership
check of spec entry 2) or full-clones the template onto the UID's
deterministic VMID with the mark in the clone call. It then uploads the
seed over any existing file, writes cores, memory, agent, tag, ide2 and
net0 when configured, grows the boot disk when diskGiB is larger, starts,
and waits for running/running.

waitUnlocked no longer waits on a suspended lock: a hibernated VM is
judged at once, so ours is Permanent and a foreign one is probed past.
The template is checked only on the allocation path, and its shape
(net0, boot disk size) is refused before any clone exists. Spec entries
2, 4 and 10 updated to match.

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

248 lines
8.4 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 Delete is implemented in Phase 7.
func TestStubs_answerTransient(t *testing.T) {
t.Parallel()
p := newProvider("proxmox-home", testConfig(), nil)
if err := p.Delete(context.Background(), "vms/150042/proxy-x"); !errors.Is(err, provider.ErrTransient) {
t.Errorf("delete error = %v, want ErrTransient", 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)
}
}