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>
253 lines
8.6 KiB
Go
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)
|
|
}
|
|
}
|