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) } }