kvm2: Create, Get, Delete and ListByTag with the contract tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 19:23:49 +00:00
co-authored by Claude Fable 5.1
parent 6b3c985b4a
commit 794a2aef3d
14 changed files with 2120 additions and 63 deletions
+415
View File
@@ -0,0 +1,415 @@
package kvm2
import (
"context"
"errors"
"fmt"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/digitalocean/go-libvirt"
"github.com/go-logr/logr/funcr"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
const (
testProvider = "kvm2-home"
testHost = "beelink"
testUID = "0f1e2d3c-uid-1"
testName = "proxy-abcdefghijklmnop"
)
// fixture is one configured host backed by a fake, an image server the
// catalogue points at, a log capture at a chosen verbosity and a provider
// with a fast running-wait, so a Create runs end to end in memory.
type fixture struct {
t *testing.T
p *Provider
fake *fakeBackend
dialer *stubDialer
logs *logCapture
ctx context.Context
image []byte
}
func newFixture(t *testing.T, verbosity int, opts ...providerOption) *fixture {
t.Helper()
fx := &fixture{t: t, fake: newFakeBackend(), logs: &logCapture{}, image: []byte("QFI\xfb fake base image bytes")}
srv := httptest.NewServer(serveBytes(fx.image))
t.Cleanup(srv.Close)
fx.dialer = &stubDialer{backend: fx.fake}
cfg := provider.KVMConfig{
Images: map[string]provider.KVMImage{imageKey: {URL: srv.URL + "/debian-13-generic-amd64.qcow2"}},
MachineTypes: map[string]provider.KVMMachineType{"small": {VCPUs: 1, MemoryMiB: 1024}},
Hosts: []provider.KVMHost{{Name: testHost, Address: "beelink:22", User: "virt", Bridge: "br0"}},
}
opts = append([]providerOption{withHTTPClient(srv.Client()), withRunningWait(time.Millisecond, 500*time.Millisecond)}, opts...)
fx.p = newProvider(testProvider, cfg, fx.dialer, opts...)
log := funcr.New(func(prefix, args string) {
fx.logs.mu.Lock()
defer fx.logs.mu.Unlock()
fx.logs.lines = append(fx.logs.lines, prefix+" "+args)
}, funcr.Options{Verbosity: verbosity})
fx.ctx = logf.IntoContext(context.Background(), log)
return fx
}
func (fx *fixture) req(name string) provider.CreateRequest {
return provider.CreateRequest{
Name: name,
UID: testUID,
Namespace: "default",
ProxyName: "egress",
Placement: provider.Placement{Zone: testHost, MachineType: "small", Image: imageKey},
CloudInit: "#cloud-config\npackages: [squid]\n",
Port: 3128,
}
}
func (fx *fixture) create(name string) string {
fx.t.Helper()
id, err := fx.p.Create(fx.ctx, fx.req(name))
if err != nil {
fx.t.Fatalf("Create(%s) error = %v", name, err)
}
if want := formatProviderID(testHost, name); id != want {
fx.t.Fatalf("Create(%s) = %q, want %q", name, id, want)
}
return id
}
func (fx *fixture) get(id string) *provider.Instance {
fx.t.Helper()
inst, err := fx.p.Get(fx.ctx, id)
if err != nil {
fx.t.Fatalf("Get(%s) error = %v", id, err)
}
return inst
}
// withDomain runs fn on the fake's domain under its lock.
func (fx *fixture) withDomain(name string, fn func(d *fakeDomain)) {
fx.t.Helper()
fx.fake.mu.Lock()
defer fx.fake.mu.Unlock()
d, ok := fx.fake.domains[name]
if !ok {
fx.t.Fatalf("fake has no domain %s", name)
}
fn(d)
}
func (fx *fixture) hasDomain(name string) bool {
fx.fake.mu.Lock()
defer fx.fake.mu.Unlock()
_, ok := fx.fake.domains[name]
return ok
}
func (fx *fixture) setAddrs(name string, src libvirt.DomainInterfaceAddressesSource, ips ...string) {
fx.withDomain(name, func(d *fakeDomain) {
var addrs []libvirt.DomainIPAddr
for _, ip := range ips {
addrs = append(addrs, libvirt.DomainIPAddr{Type: int32(libvirt.IPAddrTypeIpv4), Addr: ip, Prefix: 24})
}
d.addrs[uint32(src)] = []libvirt.DomainInterface{{Name: "enp1s0", Addrs: addrs}}
})
}
// putVol places a volume in the fake's pool directly.
func (fx *fixture) putVol(name string, content []byte) *fakeVol {
fx.fake.mu.Lock()
defer fx.fake.mu.Unlock()
v := &fakeVol{content: content, capacity: uint64(len(content))}
fx.fake.pools[fakePoolName].vols[name] = v
return v
}
// cacheImage places a complete base image and its marker, as a previous
// Create on the host would have.
func (fx *fixture) cacheImage() {
src, err := resolveImage(fx.p.cfg, imageKey)
if err != nil {
fx.t.Fatal(err)
}
fx.putVol(src.volumeName(), fx.image)
fx.putVol(src.markerName(), []byte(digestOf(fx.image)))
}
func managedMetadata(uid string, createdAt time.Time) string {
s, err := marshalInstanceMetadata(newInstanceMetadata(uid, createdAt))
if err != nil {
panic(err)
}
return s
}
// assertProviderError checks err is a *provider.Error from the configured
// provider with the given op and class.
func assertProviderError(t *testing.T, err error, op string, class error) {
t.Helper()
if err == nil {
t.Fatalf("error = nil, want op %s class %v", op, class)
}
var pe *provider.Error
if !errors.As(err, &pe) {
t.Fatalf("error = %T (%v), want *provider.Error", err, err)
}
if pe.Provider != testProvider {
t.Errorf("error.Provider = %q, want %q", pe.Provider, testProvider)
}
if pe.Op != op {
t.Errorf("error.Op = %q, want %q", pe.Op, op)
}
if !errors.Is(err, class) {
t.Errorf("error = %v, want class %v", err, class)
}
for _, other := range []error{provider.ErrNotFound, provider.ErrQuotaExceeded, provider.ErrPermanent, provider.ErrTransient} {
if other != class && errors.Is(err, other) {
t.Errorf("error = %v, also matches %v", err, other)
}
}
}
// hostDialer hands out one backend per host name and fails hosts in errs.
type hostDialer struct {
backends map[string]backend
errs map[string]error
}
func (d *hostDialer) dial(_ context.Context, host provider.KVMHost) (backend, error) {
if err := d.errs[host.Name]; err != nil {
return nil, err
}
b, ok := d.backends[host.Name]
if !ok {
return nil, fmt.Errorf("hostDialer: no backend for %s", host.Name)
}
return b, nil
}
// waitFor polls cond until it holds or the deadline passes.
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", what)
}
time.Sleep(time.Millisecond)
}
}
func TestProvider_cancelledContextReturnsPromptly(t *testing.T) {
t.Parallel()
tests := []struct {
name string
firstRPC string
op string
call func(ctx context.Context, fx *fixture) error
}{
{
name: "create", firstRPC: "DomainLookupByName", op: "create",
call: func(ctx context.Context, fx *fixture) error { _, err := fx.p.Create(ctx, fx.req(testName)); return err },
},
{
name: "get", firstRPC: "DomainLookupByName", op: "get",
call: func(ctx context.Context, fx *fixture) error {
_, err := fx.p.Get(ctx, formatProviderID(testHost, testName))
return err
},
},
{
name: "delete", firstRPC: "DomainLookupByName", op: "delete",
call: func(ctx context.Context, fx *fixture) error {
return fx.p.Delete(ctx, formatProviderID(testHost, testName))
},
},
{
name: "list", firstRPC: "ConnectListAllDomains", op: "list",
call: func(ctx context.Context, fx *fixture) error { _, err := fx.p.ListByTag(ctx); return err },
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
release := fx.fake.blockOn(tc.firstRPC)
defer close(release)
ctx, cancel := context.WithCancel(fx.ctx)
done := make(chan error, 1)
go func() { done <- tc.call(ctx, fx) }()
waitFor(t, "the first RPC to block", func() bool { return fx.fake.callCount(tc.firstRPC) == 1 })
cancel()
select {
case err := <-done:
assertProviderError(t, err, tc.op, provider.ErrTransient)
case <-time.After(time.Second):
t.Fatalf("%s did not return within 1s of cancellation", tc.name)
}
})
}
}
func TestProvider_everyErrorIsWrappedWithConfiguredName(t *testing.T) {
t.Parallel()
dialErr := errors.New("dial tcp 192.0.2.1:22: connect: connection refused")
tests := []struct {
name string
setup func(fx *fixture)
call func(fx *fixture) error
op string
class error
}{
{
name: "create unknown zone",
call: func(fx *fixture) error {
req := fx.req(testName)
req.Placement.Zone = "nas9"
_, err := fx.p.Create(fx.ctx, req)
return err
},
op: "create", class: provider.ErrPermanent,
},
{
name: "create dial failure",
setup: func(fx *fixture) { fx.dialer.err = dialErr },
call: func(fx *fixture) error { _, err := fx.p.Create(fx.ctx, fx.req(testName)); return err },
op: "create", class: provider.ErrTransient,
},
{
name: "create pool out of space",
setup: func(fx *fixture) {
fx.fake.mu.Lock()
fx.fake.pools[fakePoolName].available = 1
fx.fake.mu.Unlock()
},
call: func(fx *fixture) error { _, err := fx.p.Create(fx.ctx, fx.req(testName)); return err },
op: "create", class: provider.ErrQuotaExceeded,
},
{
name: "create vanished volume",
setup: func(fx *fixture) { fx.fake.failWith("StorageVolGetPath", fakeErr(libvirt.ErrNoStorageVol, "gone")) },
call: func(fx *fixture) error { _, err := fx.p.Create(fx.ctx, fx.req(testName)); return err },
op: "create", class: provider.ErrTransient,
},
{
name: "get malformed id",
call: func(fx *fixture) error { _, err := fx.p.Get(fx.ctx, "beelink/"+testName); return err },
op: "get", class: provider.ErrPermanent,
},
{
name: "get missing domain",
call: func(fx *fixture) error { _, err := fx.p.Get(fx.ctx, formatProviderID(testHost, testName)); return err },
op: "get", class: provider.ErrNotFound,
},
{
name: "get busy backend",
setup: func(fx *fixture) {
fx.create(testName)
fx.fake.failWith("DomainGetState", fakeErr(libvirt.ErrOperationInvalid, "busy"))
},
call: func(fx *fixture) error { _, err := fx.p.Get(fx.ctx, formatProviderID(testHost, testName)); return err },
op: "get", class: provider.ErrTransient,
},
{
name: "delete malformed id",
call: func(fx *fixture) error { return fx.p.Delete(fx.ctx, "hosts//domains/"+testName) },
op: "delete", class: provider.ErrPermanent,
},
{
name: "delete dial failure",
setup: func(fx *fixture) { fx.dialer.err = dialErr },
call: func(fx *fixture) error { return fx.p.Delete(fx.ctx, formatProviderID(testHost, testName)) },
op: "delete", class: provider.ErrTransient,
},
{
name: "delete access denied",
setup: func(fx *fixture) { fx.fake.failWith("DomainLookupByName", fakeErr(libvirt.ErrAccessDenied, "denied")) },
call: func(fx *fixture) error { return fx.p.Delete(fx.ctx, formatProviderID(testHost, testName)) },
op: "delete", class: provider.ErrPermanent,
},
{
name: "list dial failure",
setup: func(fx *fixture) { fx.dialer.err = dialErr },
call: func(fx *fixture) error { _, err := fx.p.ListByTag(fx.ctx); return err },
op: "list", class: provider.ErrTransient,
},
{
name: "list rpc failure",
setup: func(fx *fixture) {
fx.fake.failWith("ConnectListAllDomains", fakeErr(libvirt.ErrInternalError, "boom"))
},
call: func(fx *fixture) error { _, err := fx.p.ListByTag(fx.ctx); return err },
op: "list", class: provider.ErrTransient,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
if tc.setup != nil {
tc.setup(fx)
}
assertProviderError(t, tc.call(fx), tc.op, tc.class)
})
}
}
func TestLogging_v0SilentV1OutcomeV2Detail(t *testing.T) {
t.Parallel()
outcomes := []string{"overlay volume created", "seed volume uploaded", "domain defined", "kvm2 domain ensured running"}
t.Run("v0 is silent", func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 0)
fx.create(testName)
if lines := fx.logs.all(); len(lines) != 0 {
t.Errorf("Create logged %d lines at V(0), want none:\n%s", len(lines), strings.Join(lines, "\n"))
}
})
t.Run("v1 has outcomes without xml", func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 1)
fx.create(testName)
for _, want := range outcomes {
if len(fx.logs.matching(want)) != 1 {
t.Errorf("V(1) lines matching %q = %d, want 1:\n%s", want, len(fx.logs.matching(want)), strings.Join(fx.logs.all(), "\n"))
}
}
if got := fx.logs.matching("<domain"); len(got) != 0 {
t.Errorf("V(1) carries domain XML:\n%s", strings.Join(got, "\n"))
}
if got := fx.logs.matching("kvm2 create resolved"); len(got) != 0 {
t.Errorf("V(1) carries the V(2) detail line:\n%s", strings.Join(got, "\n"))
}
for _, l := range fx.logs.all() {
if !strings.Contains(l, `"provider"="kvm2-home"`) || !strings.Contains(l, `"host"="beelink"`) {
t.Errorf("line lacks provider or host: %s", l)
}
if !strings.Contains(l, `"name"="`+testName+`"`) && !strings.Contains(l, `"volume"="base-`) {
t.Errorf("instance line lacks the name: %s", l)
}
}
})
t.Run("v2 has detail", func(t *testing.T) {
t.Parallel()
fx := newFixture(t, 2)
fx.create(testName)
if got := fx.logs.matching("<domain"); len(got) == 0 {
t.Errorf("V(2) lacks domain XML:\n%s", strings.Join(fx.logs.all(), "\n"))
}
resolved := fx.logs.matching("kvm2 create resolved")
if len(resolved) != 1 {
t.Fatalf("V(2) lines matching resolved = %d, want 1", len(resolved))
}
for _, key := range []string{`"cloudInitBytes"=`, `"image"="base-debian-13.qcow2"`, `"firmware"="uefi"`, `"vcpus"=1`, `"memoryMiB"=1024`, `"machineType"="small"`, `"bridge"="br0"`, `"pool"="default"`} {
if !strings.Contains(resolved[0], key) {
t.Errorf("resolved line lacks %s: %s", key, resolved[0])
}
}
})
}