Files
egress-proxies-operator/internal/provider/kvm2/kvm2_test.go
T
kacerrandClaude Fable 5.1 2510842396 kvm2: apply the milestone review
Shorter start wait with immediate verdicts for dead or paused domains,
ListByTag without state or address RPCs, Timeout for an expired check
round, no adoption of unmarked domains, reference-counted keyed locks,
dead options removed, HEAD fallback on any non-2xx.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:19:50 +00:00

350 lines
10 KiB
Go

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