Files
egress-proxies-operator/internal/provider/kvm2/backend_test.go
T
kacerrandClaude Fable 5.1 b45f83327a kvm2: SSH transport, ctx-first libvirt seam, host table and span decorator
Every go-libvirt call runs under its own budget and a cancelled or overlong
call kills the connection; credentials are read on every dial; one span and
one log line per RPC come from a decorator applied where connections are
made.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:43:27 +00:00

253 lines
7.9 KiB
Go

package kvm2
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/digitalocean/go-libvirt"
"github.com/digitalocean/go-libvirt/libvirttest"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// blockingFn returns a func that blocks until release is closed, plus a
// counter of how often it was started.
func blockingFn(t *testing.T) (fn func() error, started *atomic.Int32) {
t.Helper()
release := make(chan struct{})
t.Cleanup(func() { close(release) })
started = &atomic.Int32{}
return func() error {
started.Add(1)
<-release
return nil
}, started
}
func TestCall_deadlineClosesConnectionAndReturnsTransient(t *testing.T) {
t.Parallel()
var killed atomic.Int32
l := newLink("beelink", func() { killed.Add(1) })
fn, started := blockingFn(t)
start := time.Now()
err := l.run(context.Background(), "DomainCreate", 50*time.Millisecond, fn)
elapsed := time.Since(start)
if !errors.Is(err, provider.ErrTransient) {
t.Fatalf("run() error = %v, want ErrTransient", err)
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("run() error = %v, want it to wrap context.DeadlineExceeded", err)
}
if !strings.Contains(err.Error(), "DomainCreate") || !strings.Contains(err.Error(), "beelink") {
t.Errorf("run() error = %q, want the call name and host in it", err)
}
if elapsed > time.Second {
t.Errorf("run() took %v, want about the 50ms budget", elapsed)
}
if killed.Load() != 1 {
t.Errorf("kill hook called %d times, want 1", killed.Load())
}
if !l.isDead() {
t.Error("link not dead after an abandoned call")
}
if !strings.Contains(l.deadReason(), "budget") {
t.Errorf("deadReason() = %q, want the budget mentioned", l.deadReason())
}
start = time.Now()
err = l.run(context.Background(), "DomainGetState", time.Minute, fn)
if !errors.Is(err, provider.ErrTransient) {
t.Fatalf("second run() error = %v, want ErrTransient", err)
}
if time.Since(start) > 100*time.Millisecond {
t.Errorf("second run() took %v, want immediate", time.Since(start))
}
if started.Load() != 1 {
t.Errorf("fn started %d times, want 1: a dead link must not touch the socket", started.Load())
}
if killed.Load() != 1 {
t.Errorf("kill hook called %d times, want still 1", killed.Load())
}
}
func TestCall_cancelUnblocksRPC(t *testing.T) {
t.Parallel()
var killed atomic.Int32
l := newLink("beelink", func() { killed.Add(1) })
fn, _ := blockingFn(t)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(10 * time.Millisecond)
cancel()
}()
start := time.Now()
err := l.run(ctx, "ConnectListAllDomains", time.Minute, fn)
if time.Since(start) > 100*time.Millisecond {
t.Errorf("run() took %v after cancel, want under 100ms", time.Since(start))
}
if !errors.Is(err, provider.ErrTransient) || !errors.Is(err, context.Canceled) {
t.Errorf("run() error = %v, want ErrTransient wrapping context.Canceled", err)
}
if killed.Load() != 1 {
t.Errorf("kill hook called %d times, want 1", killed.Load())
}
}
func TestCall_alreadyCancelledDoesNotStart(t *testing.T) {
t.Parallel()
l := newLink("beelink", nil)
fn, started := blockingFn(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := l.run(ctx, "DomainGetState", time.Minute, fn)
if !errors.Is(err, provider.ErrTransient) || !errors.Is(err, context.Canceled) {
t.Errorf("run() error = %v, want ErrTransient wrapping context.Canceled", err)
}
if started.Load() != 0 {
t.Error("fn started although ctx was already cancelled")
}
if l.isDead() {
t.Error("link marked dead although nothing was abandoned")
}
}
func TestCall_returnsFnResult(t *testing.T) {
t.Parallel()
l := newLink("beelink", nil)
want := libvirt.Error{Code: uint32(libvirt.ErrNoDomain), Message: "gone"}
err := l.run(context.Background(), "DomainLookupByName", time.Second, func() error { return want })
if !hasCode(err, libvirt.ErrNoDomain) {
t.Errorf("run() error = %v, want the libvirt error through unchanged", err)
}
if l.isDead() {
t.Error("a backend error must not kill the link")
}
}
func TestCall_killedByAnotherCallerFailsTransient(t *testing.T) {
t.Parallel()
l := newLink("beelink", nil)
fn, _ := blockingFn(t)
done := make(chan error, 1)
go func() { done <- l.run(context.Background(), "DomainGetState", time.Minute, fn) }()
time.Sleep(10 * time.Millisecond)
l.markDead("keepalive failed")
select {
case err := <-done:
if !errors.Is(err, provider.ErrTransient) || !strings.Contains(err.Error(), "keepalive failed") {
t.Errorf("run() error = %v, want ErrTransient with the death reason", err)
}
case <-time.After(time.Second):
t.Fatal("run() did not return after the link was killed")
}
}
func TestBudgets_upload(t *testing.T) {
t.Parallel()
tests := []struct {
name string
length uint64
want time.Duration
}{
{name: "seed sized", length: 400 << 10, want: defaultBudgets.seedUpload},
{name: "exactly the limit", length: smallUploadBytes, want: defaultBudgets.seedUpload},
{name: "one over the limit", length: smallUploadBytes + 1, want: defaultBudgets.transfer},
{name: "base image", length: 435 << 20, want: defaultBudgets.transfer},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := defaultBudgets.upload(tc.length); got != tc.want {
t.Errorf("upload(%d) = %v, want %v", tc.length, got, tc.want)
}
})
}
}
// mockAdapter wires a libvirtBackend to go-libvirt's own mock server over a
// net.Pipe, so the adapter is exercised with the real RPC client.
func mockAdapter(t *testing.T, b budgets) *libvirtBackend {
t.Helper()
m := libvirttest.New()
conn, err := m.Dial()
if err != nil {
t.Fatalf("mock dial: %v", err)
}
ad := newLibvirtBackend(libvirt.New(conn), "mock", conn, b)
if err := ad.connect(context.Background()); err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { _ = ad.Close() })
return ad
}
func TestLibvirtBackend_roundTripThroughMock(t *testing.T) {
t.Parallel()
ad := mockAdapter(t, defaultBudgets)
v, err := ad.ConnectGetLibVersion(context.Background())
if err != nil {
t.Fatalf("ConnectGetLibVersion() error = %v", err)
}
if v == 0 {
t.Error("ConnectGetLibVersion() = 0, want the mock's version")
}
pool, err := ad.StoragePoolLookupByName(context.Background(), "default")
if err != nil {
t.Fatalf("StoragePoolLookupByName() error = %v", err)
}
if pool.Name == "" {
t.Error("StoragePoolLookupByName() returned an empty pool")
}
if isDead(ad) {
t.Error("adapter dead after successful calls")
}
}
func TestLibvirtBackend_unansweredCallIsAbandoned(t *testing.T) {
t.Parallel()
b := defaultBudgets
b.rpc = 100 * time.Millisecond
ad := mockAdapter(t, b)
// The mock does not implement ConnectGetHostname, so it never replies.
_, err := ad.ConnectGetHostname(context.Background())
if !errors.Is(err, provider.ErrTransient) || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("ConnectGetHostname() error = %v, want ErrTransient wrapping DeadlineExceeded", err)
}
if !isDead(ad) {
t.Error("adapter not dead after an abandoned call")
}
select {
case <-ad.lv.Disconnected():
case <-time.After(time.Second):
t.Error("go-libvirt did not notice the closed socket")
}
if _, err := ad.ConnectGetLibVersion(context.Background()); !errors.Is(err, provider.ErrTransient) {
t.Errorf("call on dead adapter error = %v, want ErrTransient", err)
}
if err := ad.Close(); err != nil {
t.Errorf("Close() on dead adapter error = %v, want nil", err)
}
}
func TestLibvirtBackend_closeIsIdempotent(t *testing.T) {
t.Parallel()
ad := mockAdapter(t, defaultBudgets)
first := ad.Close()
second := ad.Close()
if first != nil || second != nil {
t.Errorf("Close() errors = %v, %v, want nil, nil", first, second)
}
if !isDead(ad) {
t.Error("adapter not dead after Close")
}
if _, err := ad.ConnectGetLibVersion(context.Background()); !errors.Is(err, provider.ErrTransient) {
t.Errorf("call after Close error = %v, want ErrTransient", err)
}
}