package kvm2 import ( "context" "errors" "strings" "sync" "sync/atomic" "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). The sink is locked because // Check logs from one goroutine per host. 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 twoHosts() provider.KVMConfig { return provider.KVMConfig{ SSHKeyFile: "/nonexistent/kvm2/id_ed25519", KnownHostsFile: "/nonexistent/kvm2/known_hosts", MachineTypes: map[string]provider.KVMMachineType{"small": {VCPUs: 1, MemoryMiB: 1024}}, Hosts: []provider.KVMHost{ {Name: "beelink", Address: "beelink:22", User: "virt", Bridge: "br0"}, {Name: "lab2", Address: "lab2:22", User: "virt", Bridge: "br1", Socket: "/run/s", Pool: "fast"}, }, } } func TestNew_doesNotDialAndWarnsOnMissingFiles(t *testing.T) { t.Parallel() ctx, lines := captureContext() cfg := twoHosts() cfg.Hosts[0].Address = "192.0.2.1:22" start := time.Now() p, err := New(ctx, provider.ProviderConfig{Name: "kvm2-home", Type: "kvm2", KVM2: &cfg}) elapsed := time.Since(start) if err != nil { t.Fatalf("New() error = %v, want nil", err) } if p == nil { t.Fatal("New() = nil provider") } if elapsed > 100*time.Millisecond { t.Errorf("New() took %v, want under 100ms (it must not dial)", elapsed) } var warnings int for _, l := range *lines { if strings.Contains(l, "WARNING:") { warnings++ } if !strings.Contains(l, `"provider"="kvm2-home"`) { t.Errorf("log line lacks the provider value: %s", l) } } if warnings != 2 { t.Errorf("got %d WARNING: lines, want 2 (key and known_hosts):\n%s", warnings, strings.Join(*lines, "\n")) } } func TestNew_insecureIgnoreHostKeyWarns(t *testing.T) { t.Parallel() ctx, lines := captureContext() cfg := twoHosts() cfg.KnownHostsFile = "" cfg.InsecureIgnoreHostKey = true if _, err := New(ctx, provider.ProviderConfig{Name: "kvm2-home", Type: "kvm2", KVM2: &cfg}); err != nil { t.Fatalf("New() error = %v, want nil", err) } found := false for _, l := range *lines { if strings.Contains(l, "WARNING:") && strings.Contains(l, "host keys are not verified") { found = true } if strings.Contains(l, "known_hosts") { t.Errorf("known_hosts warning emitted although host keys are ignored: %s", l) } } if !found { t.Errorf("no insecureIgnoreHostKey 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: "kvm2-empty", Type: "kvm2"}) if err != nil { t.Fatalf("New() error = %v, want nil", err) } kp, ok := p.(*Provider) if !ok { t.Fatalf("New() = %T, want *Provider", p) } if kp.name != "kvm2-empty" || kp.cfg.DiskSizeGB != defaultDiskSizeGB || kp.now == nil { t.Errorf("provider = %+v, want name kvm2-empty, default disk size and a clock", kp) } } func TestHost_table(t *testing.T) { t.Parallel() tests := []struct { name string hosts []provider.KVMHost zone string wantName string wantErr string }{ { name: "one host and empty zone selects it", hosts: twoHosts().Hosts[:1], zone: "", wantName: "beelink", }, { name: "several hosts and empty zone is permanent", hosts: twoHosts().Hosts, zone: "", wantErr: "placement.zone is required when kvm2.hosts lists more than one host", }, { name: "known name", hosts: twoHosts().Hosts, zone: "lab2", wantName: "lab2", }, { name: "unknown name is permanent", hosts: twoHosts().Hosts, zone: "nas9", wantErr: `host "nas9" is not in kvm2.hosts`, }, { name: "no hosts at all is permanent", hosts: nil, zone: "", wantErr: "placement.zone is required", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() p := newProvider("kvm2-home", provider.KVMConfig{Hosts: tc.hosts}, &stubDialer{}) h, err := p.host(tc.zone) if tc.wantErr != "" { if err == nil { t.Fatalf("host(%q) = %+v, want error containing %q", tc.zone, h, tc.wantErr) } if !errors.Is(err, provider.ErrPermanent) { t.Errorf("host(%q) error = %v, want ErrPermanent", tc.zone, err) } if !strings.Contains(err.Error(), tc.wantErr) { t.Errorf("host(%q) error = %q, want substring %q", tc.zone, err, tc.wantErr) } return } if err != nil { t.Fatalf("host(%q) error = %v, want nil", tc.zone, err) } if h.Name != tc.wantName { t.Errorf("host(%q).Name = %q, want %q", tc.zone, h.Name, tc.wantName) } }) } } func TestWithDefaults_table(t *testing.T) { t.Parallel() tests := []struct { name string in provider.KVMConfig check func(t *testing.T, got provider.KVMConfig) }{ { name: "empty config gets disk size and empty maps", in: provider.KVMConfig{}, check: func(t *testing.T, got provider.KVMConfig) { if got.DiskSizeGB != 10 { t.Errorf("DiskSizeGB = %d, want 10", got.DiskSizeGB) } if got.Hosts == nil || got.Images == nil { t.Errorf("Hosts = %v, Images = %v, want non-nil", got.Hosts, got.Images) } }, }, { name: "explicit disk size kept", in: provider.KVMConfig{DiskSizeGB: 40}, check: func(t *testing.T, got provider.KVMConfig) { if got.DiskSizeGB != 40 { t.Errorf("DiskSizeGB = %d, want 40", got.DiskSizeGB) } }, }, { name: "host socket and pool default", in: provider.KVMConfig{Hosts: []provider.KVMHost{{Name: "a"}, {Name: "b", Socket: "/run/s", Pool: "fast"}}}, check: func(t *testing.T, got provider.KVMConfig) { if got.Hosts[0].Socket != "/var/run/libvirt/libvirt-sock" || got.Hosts[0].Pool != "default" { t.Errorf("hosts[0] = %+v, want default socket and pool", got.Hosts[0]) } if got.Hosts[1].Socket != "/run/s" || got.Hosts[1].Pool != "fast" { t.Errorf("hosts[1] = %+v, want explicit socket and pool kept", got.Hosts[1]) } }, }, { name: "image format and firmware default", in: provider.KVMConfig{Images: map[string]provider.KVMImage{ "d": {URL: "https://x/d.qcow2"}, "u": {URL: "https://x/u.img", Format: provider.KVMFormatRaw, Firmware: provider.KVMFirmwareBIOS}, }}, check: func(t *testing.T, got provider.KVMConfig) { if got.Images["d"].Format != provider.KVMFormatQcow2 || got.Images["d"].Firmware != provider.KVMFirmwareUEFI { t.Errorf("images[d] = %+v, want qcow2 and uefi", got.Images["d"]) } if got.Images["u"].Format != provider.KVMFormatRaw || got.Images["u"].Firmware != provider.KVMFirmwareBIOS { t.Errorf("images[u] = %+v, want raw and bios kept", got.Images["u"]) } }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() tc.check(t, withDefaults(tc.in)) }) } } func TestWithDefaults_doesNotAliasInput(t *testing.T) { t.Parallel() in := provider.KVMConfig{ Hosts: []provider.KVMHost{{Name: "a"}}, Images: map[string]provider.KVMImage{"d": {URL: "https://x/d.qcow2"}}, } got := withDefaults(in) got.Hosts[0].Name = "changed" got.Images["d"] = provider.KVMImage{URL: "changed"} if in.Hosts[0].Name != "a" || in.Images["d"].URL != "https://x/d.qcow2" { t.Errorf("input mutated through the defaulted copy: %+v", in) } } func lockCount(k *keyedLocks) int { k.mu.Lock() defer k.mu.Unlock() return len(k.locks) } func TestKeyedLocks_entryRemovedWhenUncontended(t *testing.T) { t.Parallel() k := newKeyedLocks() release, err := k.acquire(context.Background(), "a") if err != nil { t.Fatalf("acquire error = %v", err) } if lockCount(k) != 1 { t.Errorf("entries while held = %d, want 1", lockCount(k)) } release() if lockCount(k) != 0 { t.Errorf("entries after release = %d, want 0", lockCount(k)) } if _, err := k.acquire(context.Background(), "a"); err != nil { t.Fatalf("acquire after release error = %v", err) } } func TestKeyedLocks_waitersShareOneLock(t *testing.T) { t.Parallel() k := newKeyedLocks() const n = 20 var inside, overlaps atomic.Int32 var wg sync.WaitGroup for range n { wg.Go(func() { release, err := k.acquire(context.Background(), "shared") if err != nil { t.Errorf("acquire error = %v", err) return } if inside.Add(1) > 1 { overlaps.Add(1) } time.Sleep(time.Millisecond) inside.Add(-1) release() }) } wg.Wait() if overlaps.Load() != 0 { t.Errorf("%d holders overlapped on one key", overlaps.Load()) } if lockCount(k) != 0 { t.Errorf("entries after every holder released = %d, want 0", lockCount(k)) } } func TestKeyedLocks_waiterGivesUpWithContext(t *testing.T) { t.Parallel() k := newKeyedLocks() release, err := k.acquire(context.Background(), "a") if err != nil { t.Fatalf("acquire error = %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) defer cancel() start := time.Now() if _, err := k.acquire(ctx, "a"); !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("waiter error = %v, want DeadlineExceeded", err) } if time.Since(start) > time.Second { t.Errorf("waiter took %v, want to give up with its ctx", time.Since(start)) } if lockCount(k) != 1 { t.Errorf("entries with the holder still in = %d, want 1: the waiter's exit must not drop the holder's entry", lockCount(k)) } release() if lockCount(k) != 0 { t.Errorf("entries after the holder released = %d, want 0", lockCount(k)) } } func TestWrapErr_carriesProviderNameOpAndID(t *testing.T) { t.Parallel() p := newProvider("kvm2-home", twoHosts(), &stubDialer{}) err := p.wrapErr("get", "hosts/beelink/domains/proxy-x", permanentf("boom")) var pe *provider.Error if !errors.As(err, &pe) { t.Fatalf("wrapErr() = %T, want *provider.Error", err) } if pe.Provider != "kvm2-home" || pe.Op != "get" || pe.ID != "hosts/beelink/domains/proxy-x" || pe.Class != provider.ErrPermanent { t.Errorf("wrapErr() = %+v, want provider kvm2-home, op get, the id and ErrPermanent", pe) } }