package kvm import ( "context" "encoding/xml" "errors" "fmt" "io" "strings" "testing" "time" libvirt "github.com/digitalocean/go-libvirt" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider" ) func TestWithDefaults(t *testing.T) { t.Parallel() cfg := withDefaults(provider.KVMConfig{ Images: map[string]provider.KVMImage{ "debian": {URL: "https://x/d.qcow2"}, "raw": {URL: "https://x/r.img", Format: "raw", Firmware: "bios"}, }, Hosts: []provider.KVMHost{ {Name: "a", Address: "a:22", User: "virt", Bridge: "br0"}, {Name: "b", Address: "b:22", User: "virt", Bridge: "br1", Socket: "/run/s", Pool: "fast"}, }, }) if cfg.DiskSizeGB != defaultDiskSizeGB { t.Errorf("diskSizeGb = %d, want %d", cfg.DiskSizeGB, defaultDiskSizeGB) } if cfg.VendorData != defaultVendorData { t.Errorf("vendorData = %q, want the default", cfg.VendorData) } if cfg.Images["debian"].Format != "qcow2" || cfg.Images["raw"].Format != "raw" { t.Errorf("image formats = %q/%q, want qcow2 default and raw kept", cfg.Images["debian"].Format, cfg.Images["raw"].Format) } if cfg.Images["debian"].Firmware != firmwareUEFI || cfg.Images["raw"].Firmware != firmwareBIOS { t.Errorf("image firmware = %q/%q, want uefi default and bios kept", cfg.Images["debian"].Firmware, cfg.Images["raw"].Firmware) } if a := cfg.Hosts[0]; a.Socket != defaultSocket || a.Pool != defaultPool { t.Errorf("host a = %+v, want default socket and pool", a) } if b := cfg.Hosts[1]; b.Socket != "/run/s" || b.Pool != "fast" { t.Errorf("host b = %+v, want explicit socket and pool kept", b) } } func TestWithDefaults_keepsExplicitValues(t *testing.T) { t.Parallel() cfg := withDefaults(provider.KVMConfig{DiskSizeGB: 42, VendorData: "#cloud-config\n"}) if cfg.DiskSizeGB != 42 || cfg.VendorData != "#cloud-config\n" { t.Errorf("cfg = %+v, want explicit values kept", cfg) } } // --- provider over fake hosts --- const ( testName = "proxy-abc123def456ghij" testUID = "11111111-2222-3333-4444-555555555555" testImageURL = "https://images.example/debian-13.qcow2" testCloudInit = "#cloud-config\npackages: [squid]\n" ) var ( testNow = time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC) errHostOff = errors.New("ssh dial: connection refused") ) // hostFakes routes each configured host name to its own fakeAPI; a host // with no entry is unreachable. type hostFakes map[string]*fakeAPI func (h hostFakes) dial(_ context.Context, host provider.KVMHost) (*hostClient, error) { api, ok := h[host.Name] if !ok { return nil, errHostOff } return &hostClient{api: api, close: func() {}}, nil } func testConfig(hosts ...string) provider.ProviderConfig { cfg := &provider.KVMConfig{ SSHKeyFile: "/k", KnownHostsFile: "/kh", Images: map[string]provider.KVMImage{"debian-13": {URL: testImageURL}}, MachineTypes: map[string]provider.KVMMachineType{"small": {VCPUs: 2, MemoryMiB: 2048}}, } for _, h := range hosts { cfg.Hosts = append(cfg.Hosts, provider.KVMHost{Name: h, Address: h + ":22", User: "virt", Bridge: "br0"}) } return provider.ProviderConfig{Name: "kvm-home", Type: "kvm", KVM: cfg} } func newTestProvider(fakes hostFakes, hosts ...string) *Provider { p := newWithDialer(testConfig(hosts...), fakes.dial, nil) p.now = func() time.Time { return testNow } return p } // cacheImage pre-populates the pool with the configured image and its // marker so Create never touches the network. func cacheImage(api *fakeAPI) { src := imageSource{Key: "debian-13", URL: testImageURL, Format: formatQcow2} api.putVol("default", src.volumeName(), formatQcow2, []byte("qcow2")) api.putVol("default", src.markerName(), formatRaw, []byte(testImageSHA())) } func readyHost() *fakeAPI { api := newFakeAPI("default") cacheImage(api) return api } func testRequest() provider.CreateRequest { return provider.CreateRequest{ Name: testName, UID: testUID, Namespace: "default", ProxyName: "home-proxy-1", Placement: provider.Placement{Zone: "nas1", MachineType: "small", Image: "debian-13"}, CloudInit: testCloudInit, Port: 3128, } } func metadataXML(t *testing.T, uid string, created time.Time) string { t.Helper() out, err := xml.Marshal(newInstanceMetadata(uid, created)) if err != nil { t.Fatal(err) } return string(out) } func ipv4(addr string) libvirt.DomainIPAddr { return libvirt.DomainIPAddr{Type: int32(libvirt.IPAddrTypeIpv4), Addr: addr, Prefix: 24} } func ipv6(addr string) libvirt.DomainIPAddr { return libvirt.DomainIPAddr{Type: int32(libvirt.IPAddrTypeIpv6), Addr: addr, Prefix: 64} } // managedDomain seeds a domain as Create would have left it. func managedDomain(t *testing.T, api *fakeAPI, name, uid string, running bool, addrs map[uint32][]libvirt.DomainInterface) { t.Helper() api.putDomain(&fakeDomain{name: name, metadata: metadataXML(t, uid, testNow), running: running, addrs: addrs}) api.putVol("default", overlayVolumeName(name), formatQcow2, nil) api.putVol("default", seedVolumeName(name), formatRaw, []byte("iso")) } func wantClass(t *testing.T, err error, class error) { t.Helper() if err == nil { t.Fatalf("got nil error, want %v", class) } if !errors.Is(err, class) { t.Fatalf("error class = %v, want %v (err: %v)", provider.Class(err), class, err) } } func assertCreated(t *testing.T, api *fakeAPI) { t.Helper() dom := api.domain(testName) if dom == nil { t.Fatal("domain not defined") } if !dom.running { t.Error("domain not running") } overlay := api.vol("default", overlayVolumeName(testName)) if overlay == nil { t.Fatal("overlay volume missing") } if overlay.backing != "/var/lib/libvirt/images/base-debian-13.qcow2" || overlay.format != formatQcow2 { t.Errorf("overlay = %+v, want qcow2 backed by the cached base", overlay) } if overlay.capacity != defaultDiskSizeGB*bytesPerGiB { t.Errorf("overlay capacity = %d, want %d", overlay.capacity, defaultDiskSizeGB*bytesPerGiB) } seed := api.vol("default", seedVolumeName(testName)) if seed == nil { t.Fatal("seed volume missing") } if seed.format != formatRaw || len(seed.data) == 0 || int64(len(seed.data)) != seed.capacity { t.Errorf("seed = format %s, %d bytes, capacity %d; want raw with capacity == uploaded size", seed.format, len(seed.data), seed.capacity) } } func TestCreate_freshHost(t *testing.T) { t.Parallel() api := readyHost() p := newTestProvider(hostFakes{"nas1": api}, "nas1") id, err := p.Create(context.Background(), testRequest()) if err != nil { t.Fatalf("Create: %v", err) } if want := "hosts/nas1/domains/" + testName; id != want { t.Errorf("providerID = %s, want %s", id, want) } assertCreated(t, api) var d domainXML if err := xml.Unmarshal([]byte(api.domain(testName).xml), &d); err != nil { t.Fatal(err) } if d.VCPU != 2 || d.Memory.Value != 2048 || d.Memory.Unit != "MiB" { t.Errorf("domain size = %d vcpu / %d %s, want 2 / 2048 MiB", d.VCPU, d.Memory.Value, d.Memory.Unit) } if d.Devices.Interfaces[0].Source.Bridge != "br0" { t.Errorf("bridge = %q, want br0", d.Devices.Interfaces[0].Source.Bridge) } wantDisk, wantSeed := api.poolPath+"/"+overlayVolumeName(testName), api.poolPath+"/"+seedVolumeName(testName) if got := d.Devices.Disks[0].Source.File; got != wantDisk { t.Errorf("overlay disk source = %q, want the path libvirt reported, %q", got, wantDisk) } if got := d.Devices.Disks[1].Source.File; got != wantSeed { t.Errorf("seed cdrom source = %q, want the path libvirt reported, %q", got, wantSeed) } if m := d.Metadata.Instance; m.UID != testUID || !m.managed() || !m.createdAt().Equal(testNow) { t.Errorf("metadata = %+v, want managed, uid %s, createdAt %s", m, testUID, testNow) } if api.hasCall("StorageVolDownload") || api.callCount("StorageVolUpload") != 1 { t.Errorf("calls = %v, want no image download and exactly one upload (the seed)", api.calls) } } func TestCreate_fetchesImageOnFirstUse(t *testing.T) { t.Parallel() srv := newImageServer(t) api := newFakeAPI("default") cfg := testConfig("nas1") cfg.KVM.Images["debian-13"] = provider.KVMImage{URL: srv.URL + "/debian.qcow2", SHA256: testImageSHA()} p := newWithDialer(cfg, hostFakes{"nas1": api}.dial, nil) if _, err := p.Create(context.Background(), testRequest()); err != nil { t.Fatalf("Create: %v", err) } if srv.hits.Load() != 1 { t.Errorf("image server hits = %d, want 1", srv.hits.Load()) } base := api.vol("default", "base-debian-13.qcow2") if base == nil || string(base.data) != string(testImage) { t.Fatal("base image not cached in the pool") } if api.domain(testName) == nil || !api.domain(testName).running { t.Error("domain not running after image fetch") } // A second Proxy with the same image on the same host reuses the cache. req := testRequest() req.Name, req.UID = "proxy-second", "uid-2" if _, err := p.Create(context.Background(), req); err != nil { t.Fatalf("second Create: %v", err) } if srv.hits.Load() != 1 { t.Errorf("image server hits after second Create = %d, want still 1", srv.hits.Load()) } } func TestCreate_isIdempotentWhenComplete(t *testing.T) { t.Parallel() api := readyHost() p := newTestProvider(hostFakes{"nas1": api}, "nas1") if _, err := p.Create(context.Background(), testRequest()); err != nil { t.Fatal(err) } before := len(api.calls) id, err := p.Create(context.Background(), testRequest()) if err != nil { t.Fatalf("second Create: %v", err) } if id != "hosts/nas1/domains/"+testName { t.Errorf("providerID = %s", id) } for _, m := range []string{"DomainDefineXML", "DomainCreate", "StorageVolUpload"} { if n := api.callCount(m); n != 1 { t.Errorf("%s called %d times across two Creates, want 1", m, n) } } if len(api.calls) == before { t.Error("second Create made no calls at all; expected lookups") } } // Each case breaks Create after a different step, then re-runs it and // expects the second attempt to pick up where the first stopped without // duplicating what already exists. func TestCreate_resumesAfterPartialFailure(t *testing.T) { t.Parallel() cases := []struct { name string sabotage func(api *fakeAPI) class error check func(t *testing.T, api *fakeAPI) }{ { name: "seed upload fails after overlay exists", sabotage: func(api *fakeAPI) { api.uploadHook = func(vol string, _ io.Reader) error { api.uploadHook = nil return io.ErrUnexpectedEOF } }, class: provider.ErrTransient, check: func(t *testing.T, api *fakeAPI) { if n := api.callCount("StorageVolUpload"); n != 2 { t.Errorf("seed uploads = %d, want 2 (failed, then redone)", n) } if n := api.callCount("StorageVolCreateXML"); n != 4 { t.Errorf("volume creates = %d, want 4 (overlay, seed; overlay rejected as existing, seed again)", n) } if names := api.volNames("default"); len(names) != 4 { t.Errorf("pool holds %v, want base, marker, one overlay, one seed", names) } }, }, { name: "define fails after volumes exist", sabotage: func(api *fakeAPI) { api.failOnce["DomainDefineXML"] = lvError(libvirt.ErrInternalError, "boom") }, class: provider.ErrTransient, check: func(t *testing.T, api *fakeAPI) { if n := api.callCount("StorageVolUpload"); n != 2 { t.Errorf("seed uploads = %d, want 2 (seed rebuilt while no domain exists)", n) } if n := api.callCount("DomainDefineXML"); n != 2 { t.Errorf("defines = %d, want 2", n) } }, }, { name: "start fails after define", sabotage: func(api *fakeAPI) { api.failOnce["DomainCreate"] = lvError(libvirt.ErrInternalError, "boom") }, class: provider.ErrTransient, check: func(t *testing.T, api *fakeAPI) { if n := api.callCount("DomainDefineXML"); n != 1 { t.Errorf("defines = %d, want 1 (existing domain reused)", n) } if n := api.callCount("DomainCreate"); n != 2 { t.Errorf("starts = %d, want 2", n) } }, }, { name: "transport drops mid-create", sabotage: func(api *fakeAPI) { api.failOnce["DomainLookupByName"] = io.EOF }, class: provider.ErrTransient, check: func(t *testing.T, api *fakeAPI) { if n := api.callCount("DomainDefineXML"); n != 1 { t.Errorf("defines = %d, want 1", n) } }, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() api := readyHost() tc.sabotage(api) p := newTestProvider(hostFakes{"nas1": api}, "nas1") _, err := p.Create(context.Background(), testRequest()) wantClass(t, err, tc.class) if _, err := p.Create(context.Background(), testRequest()); err != nil { t.Fatalf("retry Create: %v", err) } assertCreated(t, api) tc.check(t, api) }) } } // A reconcile cancelled mid-upload leaves a seed volume whose content is // short; the compensating delete cannot run because the pool has already // closed the connection. The next Create must not trust that volume. func TestCreate_replacesLeftoverSeed(t *testing.T) { t.Parallel() api := readyHost() api.putVol("default", seedVolumeName(testName), formatRaw, []byte("trunc")) p := newTestProvider(hostFakes{"nas1": api}, "nas1") if _, err := p.Create(context.Background(), testRequest()); err != nil { t.Fatal(err) } assertCreated(t, api) if seed := api.vol("default", seedVolumeName(testName)); string(seed.data) == "trunc" { t.Error("leftover seed was kept") } if n := api.callCount("StorageVolUpload"); n != 1 { t.Errorf("uploads = %d, want 1 (the rebuilt seed)", n) } } func TestCreate_startsStoppedDomain(t *testing.T) { t.Parallel() api := readyHost() managedDomain(t, api, testName, testUID, false, nil) p := newTestProvider(hostFakes{"nas1": api}, "nas1") if _, err := p.Create(context.Background(), testRequest()); err != nil { t.Fatal(err) } if !api.domain(testName).running { t.Error("domain still stopped") } if api.hasCall("DomainDefineXML") || api.hasCall("StorageVolUpload") || api.hasCall("StorageVolDelete") { t.Errorf("calls = %v, want no define, upload or volume delete for an existing domain", api.calls) } } func TestCreate_singleHostNeedsNoZone(t *testing.T) { t.Parallel() api := readyHost() p := newTestProvider(hostFakes{"nas1": api}, "nas1") req := testRequest() req.Placement.Zone = "" id, err := p.Create(context.Background(), req) if err != nil { t.Fatal(err) } if id != "hosts/nas1/domains/"+testName { t.Errorf("providerID = %s, want the single host filled in", id) } } func TestCreate_placementErrors(t *testing.T) { t.Parallel() cases := []struct { name string hosts []string edit func(*provider.CreateRequest) want string }{ {"unknown host", []string{"nas1"}, func(r *provider.CreateRequest) { r.Placement.Zone = "nas9" }, "not in kvm.hosts"}, {"empty zone with two hosts", []string{"nas1", "nas2"}, func(r *provider.CreateRequest) { r.Placement.Zone = "" }, "placement.zone is required"}, {"empty zone with no hosts", nil, func(r *provider.CreateRequest) { r.Placement.Zone = "" }, "kvm.hosts is empty"}, {"unknown machine type", []string{"nas1"}, func(r *provider.CreateRequest) { r.Placement.MachineType = "huge" }, "not in kvm.machineTypes"}, {"empty machine type", []string{"nas1"}, func(r *provider.CreateRequest) { r.Placement.MachineType = "" }, "not in kvm.machineTypes"}, {"unknown image", []string{"nas1"}, func(r *provider.CreateRequest) { r.Placement.Image = "windows" }, "neither a key"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() fakes := hostFakes{} for _, h := range tc.hosts { fakes[h] = readyHost() } p := newTestProvider(fakes, tc.hosts...) req := testRequest() tc.edit(&req) _, err := p.Create(context.Background(), req) wantClass(t, err, provider.ErrPermanent) if !strings.Contains(err.Error(), tc.want) { t.Errorf("error = %q, want it to mention %q", err, tc.want) } for _, api := range fakes { if len(api.calls) != 0 { t.Errorf("host was called: %v", api.calls) } } }) } } func TestCreate_hostFailures(t *testing.T) { t.Parallel() cases := []struct { name string setup func() (hostFakes, string) class error }{ { name: "host unreachable", setup: func() (hostFakes, string) { return hostFakes{}, "nas1" }, class: provider.ErrTransient, }, { name: "pool missing", setup: func() (hostFakes, string) { api := newFakeAPI("other") return hostFakes{"nas1": api}, "nas1" }, class: provider.ErrPermanent, }, { name: "domain xml rejected", setup: func() (hostFakes, string) { api := readyHost() api.fail["DomainDefineXML"] = lvError(libvirt.ErrConfigUnsupported, "no q35 here") return hostFakes{"nas1": api}, "nas1" }, class: provider.ErrPermanent, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() fakes, host := tc.setup() p := newTestProvider(fakes, host) _, err := p.Create(context.Background(), testRequest()) wantClass(t, err, tc.class) }) } } func TestCreate_cancelledContext(t *testing.T) { t.Parallel() api := readyHost() p := newTestProvider(hostFakes{"nas1": api}, "nas1") ctx, cancel := context.WithCancel(context.Background()) cancel() _, err := p.Create(ctx, testRequest()) wantClass(t, err, provider.ErrTransient) if !errors.Is(err, context.Canceled) { t.Errorf("error = %v, want it to unwrap to context.Canceled", err) } } func TestGet(t *testing.T) { t.Parallel() arp := map[uint32][]libvirt.DomainInterface{ uint32(libvirt.DomainInterfaceAddressesSrcArp): {{Name: "vnet0", Addrs: []libvirt.DomainIPAddr{ipv4("192.168.1.50")}}}, } agentOnly := map[uint32][]libvirt.DomainInterface{ uint32(libvirt.DomainInterfaceAddressesSrcAgent): { {Name: "lo", Addrs: []libvirt.DomainIPAddr{ipv4("127.0.0.1"), ipv6("::1")}}, {Name: "enp1s0", Addrs: []libvirt.DomainIPAddr{ipv6("fe80::1"), ipv4("192.168.1.51")}}, }, } linkLocalOnly := map[uint32][]libvirt.DomainInterface{ uint32(libvirt.DomainInterfaceAddressesSrcAgent): { {Name: "enp1s0", Addrs: []libvirt.DomainIPAddr{ipv4("169.254.10.10")}}, }, } cases := []struct { name string running bool addrs map[uint32][]libvirt.DomainInterface state provider.InstanceState ip string }{ {"running with arp address", true, arp, provider.StateRunning, "192.168.1.50"}, {"running, agent only, skips loopback and v6", true, agentOnly, provider.StateRunning, "192.168.1.51"}, {"running, no address yet", true, nil, provider.StateProvisioning, ""}, {"running, only link-local", true, linkLocalOnly, provider.StateProvisioning, ""}, {"shut off", false, arp, provider.StateStopped, ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() api := readyHost() managedDomain(t, api, testName, testUID, tc.running, tc.addrs) p := newTestProvider(hostFakes{"nas1": api}, "nas1") inst, err := p.Get(context.Background(), "hosts/nas1/domains/"+testName) if err != nil { t.Fatalf("Get: %v", err) } want := provider.Instance{ID: "hosts/nas1/domains/" + testName, IP: tc.ip, State: tc.state, UID: testUID, CreatedAt: testNow} if *inst != want { t.Errorf("instance = %+v, want %+v", *inst, want) } }) } } func TestGet_unmanagedDomainHasNoUID(t *testing.T) { t.Parallel() api := readyHost() api.putDomain(&fakeDomain{name: "hand-made", running: true}) p := newTestProvider(hostFakes{"nas1": api}, "nas1") inst, err := p.Get(context.Background(), "hosts/nas1/domains/hand-made") if err != nil { t.Fatalf("Get: %v", err) } if inst.UID != "" || !inst.CreatedAt.IsZero() { t.Errorf("instance = %+v, want empty UID and zero CreatedAt", inst) } } func TestGet_errors(t *testing.T) { t.Parallel() cases := []struct { name string id string setup func(t *testing.T, api *fakeAPI) class error }{ {"missing domain", "hosts/nas1/domains/" + testName, func(*testing.T, *fakeAPI) {}, provider.ErrNotFound}, {"malformed id", "zones/x/instances/y", func(*testing.T, *fakeAPI) {}, provider.ErrPermanent}, {"host not configured", "hosts/nas9/domains/" + testName, func(*testing.T, *fakeAPI) {}, provider.ErrPermanent}, { "state read fails", "hosts/nas1/domains/" + testName, func(t *testing.T, api *fakeAPI) { managedDomain(t, api, testName, testUID, true, nil) api.fail["DomainGetState"] = lvError(libvirt.ErrInternalError, "boom") }, provider.ErrTransient, }, { "corrupt metadata", "hosts/nas1/domains/" + testName, func(_ *testing.T, api *fakeAPI) { api.putDomain(&fakeDomain{name: testName, metadata: "false`}) p := newTestProvider(hostFakes{"nas1": nas1, "nas2": nas2}, "nas1", "nas2") got, err := p.ListByTag(context.Background()) if err != nil { t.Fatalf("ListByTag: %v", err) } want := []provider.Instance{ {ID: "hosts/nas1/domains/proxy-one", IP: "192.168.1.10", State: provider.StateRunning, UID: "uid-1", CreatedAt: testNow}, {ID: "hosts/nas2/domains/proxy-two", State: provider.StateStopped, UID: "uid-2", CreatedAt: testNow}, } if len(got) != len(want) { t.Fatalf("got %d instances %+v, want %d", len(got), got, len(want)) } for i := range want { if got[i] != want[i] { t.Errorf("instance[%d] = %+v, want %+v", i, got[i], want[i]) } } } func TestListByTag_oneHostDownFailsSweep(t *testing.T) { t.Parallel() nas1 := readyHost() managedDomain(t, nas1, "proxy-one", "uid-1", true, nil) p := newTestProvider(hostFakes{"nas1": nas1}, "nas1", "nas2") got, err := p.ListByTag(context.Background()) wantClass(t, err, provider.ErrTransient) if got != nil { t.Errorf("got %+v alongside the error, want nil (no partial view)", got) } } // A domain that cannot be read is left out of the sweep rather than // failing it: GC never deletes what it does not see, so skipping is the // safe direction, while one bad domain blocking GC for all others is not. func TestListByTag_skipsUnreadableDomain(t *testing.T) { t.Parallel() cases := []struct { name string setup func(t *testing.T, api *fakeAPI) }{ { "deleted mid-sweep", func(t *testing.T, api *fakeAPI) { managedDomain(t, api, "proxy-one", "uid-1", true, nil) api.failOnce["DomainGetMetadata"] = lvError(libvirt.ErrNoDomain, "gone") }, }, { "corrupt metadata", func(_ *testing.T, api *fakeAPI) { api.putDomain(&fakeDomain{name: "proxy-one", metadata: "