baseVolumeName was base-<key>.<format>, byte-identical to what the kvm provider builds in the same pool -- for URL images too, since kvm takes hex(sum[:8]) and kvm2 takes hex(sum[:])[:16], the same eight bytes -- with a completely separate lock table. The shipped config has a live kvm-home entry and a commented-out kvm2-home entry on the same host, the same key debian-13 and the same default pool, and the comment invites the operator to enable it. Both integration tests use key test-image. On beelink both live overlays back onto kvm's base-debian-13.qcow2. Since cachedImage accepts any 64-hex marker when no sha256 is configured and never re-hashes the base, that meant two entries pointing one key at different URLs silently shared a file; a concurrent cold fetch let one entry delete the other's half-uploaded base and boot a guest from the remains; and a sha256 edit deleted the base in place under live overlays, which libvirt permits without complaint. The name now carries the provider entry: base-<entry>-<key>.<format>. imageSource gained an Entry field and resolveImage takes the provider name, so every production call site follows from imageSource.volumeName(). That closes the cross-entry sharing outright, kvm<->kvm2 and kvm2<->kvm2 alike, and turns the per-Provider imageLocks from a latent bug into a correct one: with names unique per entry, two entries cannot contend for a file, so no process-wide lock table is needed. The entry name now reaches a file name, so New checks it against the existing kvmIdentifier rule, exported as provider.IsKVMIdentifier rather than duplicated. Provider names are otherwise validated for all types at once and only for being non-empty and unique, so a name with a slash would have produced a volume nothing could address. One companion fix, because the naming alone does not stop it: cachedImage's digest-mismatch branch deleted the marker and the base and refetched. It now returns ErrPermanent naming both digests and telling the operator what to do. That branch was the remaining path that unlinks a good file under running domains, and the contract already said "add a new key instead" -- the code was contradicting it. The no-marker and non-hex-marker branches keep deleting, since there the base is debris. Not fixed, and said so in the docs rather than left to look closed: editing images.<key>.url is still a silent no-op, since nothing records the URL; and cross-process fencing still needs the deferred atomic publish (upload to a temp name, verify, publish), which lands where H2 put the discard. Migration is a non-event. The base-debian-13.qcow2 on beelink is kvm's, kvm keeps using it untouched, and kvm2 downloads its own copy once per host. Nothing is replaced under a running guest; the pool needs room for both, which the prerequisites now say. The eviction recipe in the guide named base-debian-13.qcow2, which after this change is kvm's file -- following it would have deleted the wrong provider's base. It now greps for the prefix and deletes the marker first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
427 lines
13 KiB
Go
427 lines
13 KiB
Go
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
|
|
}
|
|
|
|
// baseName is the cached base image's volume name for this fixture's
|
|
// provider entry and image key.
|
|
func (fx *fixture) baseName() string {
|
|
fx.t.Helper()
|
|
src, err := resolveImage(fx.p.name, fx.p.cfg, imageKey)
|
|
if err != nil {
|
|
fx.t.Fatal(err)
|
|
}
|
|
return src.volumeName()
|
|
}
|
|
|
|
// 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.name, 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-` + testProvider + `-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])
|
|
}
|
|
}
|
|
})
|
|
}
|