Delete stops and destroys a VM of ours under the per-name lock, then deletes its seed on the VM's node, or on every node when no VM of ours was left. A hibernated VM of ours is Permanent, as in Create. The Checker verifies each node concurrently (version, node status, ISO storage, templates) and adds CertificateInvalid, CredentialsUnavailable, StorageNotFound and TemplateNotFound to the shared reasons. The ProviderHealth type comment gains proxmox. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
238 lines
8.0 KiB
Go
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()
|
|
_, 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)
|
|
}
|
|
}
|