kvm2: config block, constructor registration, IDs, state mapping and classify

The kvm2 block reuses KVMConfig; validateKVM gains a block-name variant so
messages say kvm2. The package skeleton has a non-dialing New, strict
providerID parsing, the state table and a 34-row classify test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 16:38:48 +00:00
co-authored by Claude Fable 5.1
parent 4bd9aae03b
commit 95768281be
14 changed files with 1080 additions and 12 deletions
+3 -1
View File
@@ -59,6 +59,7 @@ import (
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/gcp"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/kubernetes"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/kvm"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/kvm2"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/registry"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/providerhealth"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
@@ -201,7 +202,8 @@ func main() {
"gcp": func(ctx context.Context, pc provider.ProviderConfig) (provider.Provider, error) {
return gcp.NewWithWireOptions(ctx, pc, gcp.WireLogOptions{FullPayloads: gcpWireFullPayloads})
},
"kvm": kvm.New,
"kvm": kvm.New,
"kvm2": kvm2.New,
})
if err != nil {
setupLog.Error(err, "Failed to build providers")
@@ -4,7 +4,7 @@ Plan: [docs/plans/2026-09-16-1333-kvm2-provider.md](../plans/2026-09-16-1333-kvm
- [x] Step 0 — Plan file and branch
- [x] Step 1 — The spec, `docs/kvm2-provider-spec.md`
- [ ] Step 2 — Config wiring, IDs, state mapping, classify
- [x] Step 2 — Config wiring, IDs, state mapping, classify
- [ ] Step 3 — Pure builders: domain and volume XML, metadata, seed
- [ ] Step 4 — Transport, seam, decorator, fakes
- [ ] Step 5 — Image cache and pool-space precheck
@@ -53,3 +53,44 @@ loop from the docs-split log. All clean.
Worth noting: the spec's entry 21 lists the concrete test name for every
contract §11 item, so the later steps' briefs can quote it rather than
re-derive the mapping.
## Step 2 — Config wiring, IDs, state mapping, classify
2026-09-16 16:40. First subagent step. The brief named the allowed files,
the spec entries, the test names and the prohibition on
`internal/provider/kvm/`; the agent reported nothing opened there, with one
honest caveat: a `golangci-lint` run printed seven single source lines from
v1 files among its findings, which it did not act on. The review greps
(`grep -rn 'provider/kvm"' internal/provider/kvm2/`, the v1 identifier
smell list) are clean.
What landed: `ProviderConfig.KVM2` reusing `KVMConfig`, `setBlocks`, the
allow-list and dispatch in `Config.validate`, `validateKVM` refactored into
`validateKVMBlock(prefix, block, c)` with `validateKVM` kept as a wrapper so
v1 messages and tests are untouched, `"kvm2": kvm2.New` in `cmd/main.go`
(both type lists in one commit), and the package skeleton: `kvm2.go`
(`New` never dials, warns on unreadable files, `withDefaults`, `host`,
`wrapErr`, stubbed methods), `id.go`, `state.go`, `errors.go` with 34
`classify` rows.
Decisions the agent made that the spec left open, all accepted: `New`
checks file existence only (parsing belongs to the dialer); `withDefaults`
copies hosts and images; `firstIPv4` also drops an address whose type tag and
literal disagree; there is no explicit `fs.ErrNotExist` branch in `classify`
because it falls to the Transient default anyway.
Commands as run by the agent and re-run by the orchestrator:
```sh
go vet ./... && go build ./... && go test -race -count=1 ./internal/provider/... ./cmd/...
go test -short ./...
```
All packages `ok`.
Worth noting: `bin/golangci-lint` panics inside staticcheck's IR builder on
this machine (`unexpected expr: *ast.KeyValueExpr` on stdlib `internal/poll`)
because the local toolchain is Go 1.27.0 while `go.mod` says 1.26 and the
bundled staticcheck predates it; it reproduces on untouched packages, so lint
is deferred to Step 10 with a toolchain note. The `.claude/settings.json`
modification is still local and uncommitted.
+11 -1
View File
@@ -22,6 +22,9 @@ type ProviderConfig struct {
Kubernetes *KubernetesConfig `json:"kubernetes,omitempty"`
GCP *GCPConfig `json:"gcp,omitempty"`
KVM *KVMConfig `json:"kvm,omitempty"`
// KVM2 is the same block shape as KVM, for the kvm2 provider type: one
// or more libvirt/KVM hypervisors reached over SSH.
KVM2 *KVMConfig `json:"kvm2,omitempty"`
}
// setBlocks names the type-specific blocks that are non-nil, so validate can
@@ -37,6 +40,9 @@ func (p ProviderConfig) setBlocks() []string {
if p.KVM != nil {
out = append(out, "kvm")
}
if p.KVM2 != nil {
out = append(out, "kvm2")
}
return out
}
@@ -100,7 +106,7 @@ func (c *Config) validate() error {
prefix := fmt.Sprintf("providers[%d] %q", i, p.Name)
switch p.Type {
case "kubernetes", "gcp", "kvm":
case "kubernetes", "gcp", "kvm", "kvm2":
case "":
return fmt.Errorf("%s: type is required", prefix)
default:
@@ -120,6 +126,10 @@ func (c *Config) validate() error {
if err := validateKVM(prefix, p.KVM); err != nil {
return err
}
case "kvm2":
if err := validateKVMBlock(prefix, "kvm2", p.KVM2); err != nil {
return err
}
}
}
return nil
+16 -9
View File
@@ -114,20 +114,27 @@ var sha256Hex = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
var kvmIdentifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
func validateKVM(prefix string, c *KVMConfig) error {
return validateKVMBlock(prefix, "kvm", c)
}
// validateKVMBlock checks a KVMConfig under the given block name, so the
// kvm and kvm2 provider types share one validator and each reports its own
// block in messages.
func validateKVMBlock(prefix, block string, c *KVMConfig) error {
if c == nil {
return fmt.Errorf("%s: kvm block is required", prefix)
return fmt.Errorf("%s: %s block is required", prefix, block)
}
if c.SSHKeyFile == "" {
return fmt.Errorf("%s: kvm.sshKeyFile is required", prefix)
return fmt.Errorf("%s: %s.sshKeyFile is required", prefix, block)
}
if c.KnownHostsFile == "" && !c.InsecureIgnoreHostKey {
return fmt.Errorf("%s: kvm.knownHostsFile is required unless kvm.insecureIgnoreHostKey is true", prefix)
return fmt.Errorf("%s: %s.knownHostsFile is required unless %s.insecureIgnoreHostKey is true", prefix, block, block)
}
if c.DiskSizeGB < 0 {
return fmt.Errorf("%s: kvm.diskSizeGb must not be negative", prefix)
return fmt.Errorf("%s: %s.diskSizeGb must not be negative", prefix, block)
}
for name, img := range c.Images {
p := fmt.Sprintf("%s: kvm.images[%q]", prefix, name)
p := fmt.Sprintf("%s: %s.images[%q]", prefix, block, name)
if !kvmIdentifier.MatchString(name) {
return fmt.Errorf("%s: key must be letters, digits, dots, dashes or underscores"+
" — it becomes the cached volume name base-<key>.<format>", p)
@@ -146,19 +153,19 @@ func validateKVM(prefix string, c *KVMConfig) error {
}
}
if len(c.MachineTypes) == 0 {
return fmt.Errorf("%s: kvm.machineTypes must define at least one profile", prefix)
return fmt.Errorf("%s: %s.machineTypes must define at least one profile", prefix, block)
}
for name, mt := range c.MachineTypes {
if mt.VCPUs <= 0 || mt.MemoryMiB <= 0 {
return fmt.Errorf("%s: kvm.machineTypes[%q] needs vcpus > 0 and memoryMiB > 0", prefix, name)
return fmt.Errorf("%s: %s.machineTypes[%q] needs vcpus > 0 and memoryMiB > 0", prefix, block, name)
}
}
if len(c.Hosts) == 0 {
return fmt.Errorf("%s: kvm.hosts must list at least one host", prefix)
return fmt.Errorf("%s: %s.hosts must list at least one host", prefix, block)
}
seen := make(map[string]bool, len(c.Hosts))
for i, h := range c.Hosts {
p := fmt.Sprintf("%s: kvm.hosts[%d]", prefix, i)
p := fmt.Sprintf("%s: %s.hosts[%d]", prefix, block, i)
if h.Name == "" {
return fmt.Errorf("%s: name is required", p)
}
+94
View File
@@ -214,6 +214,100 @@ providers:
}
}
func TestLoadConfig_kvm2Valid(t *testing.T) {
t.Parallel()
yaml := strings.Replace(validKVMYAML, "type: kvm\n kvm:\n", "type: kvm2\n kvm2:\n", 1)
cfg, err := LoadConfig([]byte(yaml))
if err != nil {
t.Fatalf("LoadConfig() error = %v, want nil", err)
}
if cfg.Providers[0].KVM != nil {
t.Errorf("providers[0].kvm = %+v, want nil", cfg.Providers[0].KVM)
}
k := cfg.Providers[0].KVM2
if k == nil {
t.Fatal("providers[0].kvm2 = nil")
}
if k.SSHKeyFile != "/etc/proxy-operator/kvm/id_ed25519" || k.KnownHostsFile != "/etc/proxy-operator/kvm/known_hosts" || k.DiskSizeGB != 20 {
t.Errorf("kvm2 = %+v, want sshKeyFile, knownHostsFile and diskSizeGb parsed", k)
}
if k.Images["ubuntu"].Format != "raw" {
t.Errorf("images[ubuntu].format = %q, want raw", k.Images["ubuntu"].Format)
}
if mt := k.MachineTypes["small"]; mt.VCPUs != 1 || mt.MemoryMiB != 1024 {
t.Errorf("machineTypes[small] = %+v, want {1 1024}", mt)
}
if len(k.Hosts) != 2 || k.Hosts[0].Name != "nas1" || k.Hosts[1].Pool != "fast" {
t.Errorf("hosts = %+v, want nas1 and lab2 with lab2's pool parsed", k.Hosts)
}
}
func TestLoadConfig_kvm2Invalid(t *testing.T) {
t.Parallel()
minimal := func(body string) string {
return "providers:\n - name: kvm2-home\n type: kvm2\n kvm2:\n" + indent(body, 6)
}
hostsAndTypes := `
machineTypes:
small: {vcpus: 1, memoryMiB: 1024}
hosts:
- {name: nas1, address: nas1:22, user: virt, bridge: br0}
`
tests := []struct {
name string
yaml string
wantErrSub string
}{
{
name: "missing kvm2 block",
yaml: "providers:\n - name: kvm2-home\n type: kvm2\n",
wantErrSub: "kvm2 block is required",
},
{
name: "missing sshKeyFile",
yaml: minimal("knownHostsFile: /kh\n" + hostsAndTypes),
wantErrSub: "kvm2.sshKeyFile is required",
},
{
name: "missing knownHostsFile without insecure flag",
yaml: minimal("sshKeyFile: /key\n" + hostsAndTypes),
wantErrSub: "kvm2.knownHostsFile is required unless kvm2.insecureIgnoreHostKey is true",
},
{
name: "no machine types",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nhosts:\n - {name: nas1, address: nas1:22, user: virt, bridge: br0}\n"),
wantErrSub: "kvm2.machineTypes must define at least one",
},
{
name: "no hosts",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\n"),
wantErrSub: "kvm2.hosts must list at least one host",
},
{
name: "duplicate host name",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nmachineTypes:\n small: {vcpus: 1, memoryMiB: 1024}\nhosts:\n - {name: nas1, address: a:22, user: virt, bridge: br0}\n - {name: nas1, address: b:22, user: virt, bridge: br0}\n"),
wantErrSub: `kvm2.hosts[1]: duplicate host name "nas1"`,
},
{
name: "image key with a slash",
yaml: minimal("sshKeyFile: /key\nknownHostsFile: /kh\nimages:\n ../etc/d: {url: https://x/y.qcow2}\n" + hostsAndTypes),
wantErrSub: `kvm2.images["../etc/d"]: key must be letters, digits, dots, dashes or underscores`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := LoadConfig([]byte(tc.yaml))
if err == nil {
t.Fatalf("LoadConfig() error = nil, want error containing %q", tc.wantErrSub)
}
if !strings.Contains(err.Error(), tc.wantErrSub) {
t.Errorf("LoadConfig() error = %q, want substring %q", err.Error(), tc.wantErrSub)
}
})
}
}
func indent(s string, n int) string {
pad := strings.Repeat(" ", n)
lines := strings.Split(strings.TrimLeft(s, "\n"), "\n")
+20
View File
@@ -118,6 +118,26 @@ providers:
image: custom-image`,
wantErrSub: "type is gcp but a kubernetes block is set",
},
{
name: "kvm type with kvm2 block",
yaml: `
providers:
- name: p1
type: kvm
kvm2:
sshKeyFile: /key`,
wantErrSub: "type is kvm but a kvm2 block is set",
},
{
name: "kvm2 type with kvm block",
yaml: `
providers:
- name: p1
type: kvm2
kvm:
sshKeyFile: /key`,
wantErrSub: "type is kvm2 but a kvm block is set",
},
{
name: "strict mode rejects unknown top-level key",
yaml: `
+97
View File
@@ -0,0 +1,97 @@
package kvm2
import (
"errors"
"fmt"
"strings"
"github.com/digitalocean/go-libvirt"
"golang.org/x/crypto/ssh/knownhosts"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// permanentf and transientf classify an error at the source, so classify
// keeps that class no matter what wraps it later.
func permanentf(format string, args ...any) error {
return fmt.Errorf("%w: %s", provider.ErrPermanent, fmt.Sprintf(format, args...))
}
func transientf(format string, args ...any) error {
return fmt.Errorf("%w: %s", provider.ErrTransient, fmt.Sprintf(format, args...))
}
// sshAuthFailed is the substring x/crypto/ssh puts in every authentication
// failure ("ssh: handshake failed: ssh: unable to authenticate, attempted
// methods [...]").
const sshAuthFailed = "unable to authenticate"
// classify maps any error reaching the provider's boundary to one of the
// four contract sentinels, per spec entry 13. op is the contract operation:
// a vanished domain or volume inside Create is Transient, because the
// contract forbids NotFound from Create.
func classify(op string, err error) error {
if err == nil {
return provider.ErrTransient
}
for _, class := range []error{provider.ErrNotFound, provider.ErrQuotaExceeded, provider.ErrPermanent, provider.ErrTransient} {
if errors.Is(err, class) {
return class
}
}
if code, ok := libvirtCode(err); ok {
return classifyLibvirt(op, code)
}
var keyErr *knownhosts.KeyError
if errors.As(err, &keyErr) {
return provider.ErrPermanent
}
if strings.Contains(err.Error(), sshAuthFailed) {
return provider.ErrPermanent
}
// Everything else retries: a missing key or known_hosts file (the
// optional Secret has not landed yet), context cancellation and
// deadlines, net.OpError, io.EOF, and anything unrecognised.
return provider.ErrTransient
}
func classifyLibvirt(op string, code libvirt.ErrorNumber) error {
switch code {
case libvirt.ErrNoDomain, libvirt.ErrNoStorageVol:
if op == "create" {
return provider.ErrTransient
}
return provider.ErrNotFound
case libvirt.ErrNoStoragePool, libvirt.ErrXMLError, libvirt.ErrNoSupport, libvirt.ErrInvalidArg,
libvirt.ErrAuthFailed, libvirt.ErrAccessDenied:
return provider.ErrPermanent
default:
return provider.ErrTransient
}
}
// libvirtCode extracts the libvirt error number from err, if it is or wraps
// a libvirt.Error. That type has a value receiver, so errors.As needs a
// value target.
func libvirtCode(err error) (libvirt.ErrorNumber, bool) {
var le libvirt.Error
if !errors.As(err, &le) {
return 0, false
}
return libvirt.ErrorNumber(le.Code), true
}
func hasCode(err error, code libvirt.ErrorNumber) bool {
got, ok := libvirtCode(err)
return ok && got == code
}
// libvirtErrorFields returns the key/value pairs a log line carries for a
// libvirt error, or nil when err is not one.
func libvirtErrorFields(err error) []any {
code, ok := libvirtCode(err)
if !ok {
return nil
}
return []any{"libvirtCode", int(code), "libvirtError", code.String()}
}
+146
View File
@@ -0,0 +1,146 @@
package kvm2
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"net"
"os"
"testing"
"github.com/digitalocean/go-libvirt"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
func libvirtErr(code libvirt.ErrorNumber, msg string) error {
return libvirt.Error{Code: uint32(code), Message: msg}
}
// Captured from x/crypto/ssh when the offered key is not in authorized_keys.
const sshAuthFailedMsg = "ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain"
func TestClassify_table(t *testing.T) {
t.Parallel()
tests := []struct {
name string
op string
err error
want error
}{
{name: "nil is transient", op: "get", err: nil, want: provider.ErrTransient},
{name: "ErrNoDomain on get", op: "get", err: libvirtErr(libvirt.ErrNoDomain, "Domain not found: no domain with matching name 'proxy-x'"), want: provider.ErrNotFound},
{name: "ErrNoDomain on delete", op: "delete", err: libvirtErr(libvirt.ErrNoDomain, "Domain not found"), want: provider.ErrNotFound},
{name: "ErrNoDomain on create is transient", op: "create", err: libvirtErr(libvirt.ErrNoDomain, "Domain not found"), want: provider.ErrTransient},
{name: "ErrNoStorageVol on get", op: "get", err: libvirtErr(libvirt.ErrNoStorageVol, "Storage volume not found: no storage vol with matching name 'proxy-x.qcow2'"), want: provider.ErrNotFound},
{name: "ErrNoStorageVol on create is transient", op: "create", err: libvirtErr(libvirt.ErrNoStorageVol, "Storage volume not found"), want: provider.ErrTransient},
{name: "ErrNoStoragePool", op: "create", err: libvirtErr(libvirt.ErrNoStoragePool, "Storage pool not found: no storage pool with matching name 'default'"), want: provider.ErrPermanent},
{name: "ErrXMLError", op: "create", err: libvirtErr(libvirt.ErrXMLError, "XML error: missing domain name"), want: provider.ErrPermanent},
{name: "ErrNoSupport", op: "create", err: libvirtErr(libvirt.ErrNoSupport, "this function is not supported by the connection driver"), want: provider.ErrPermanent},
{name: "ErrInvalidArg", op: "get", err: libvirtErr(libvirt.ErrInvalidArg, "invalid argument: unsupported flags"), want: provider.ErrPermanent},
{name: "ErrAuthFailed", op: "list", err: libvirtErr(libvirt.ErrAuthFailed, "authentication failed: access denied by policy"), want: provider.ErrPermanent},
{name: "ErrAccessDenied", op: "list", err: libvirtErr(libvirt.ErrAccessDenied, "access denied"), want: provider.ErrPermanent},
{name: "ErrOperationInvalid is transient", op: "create", err: libvirtErr(libvirt.ErrOperationInvalid, "Requested operation is not valid: domain is already running"), want: provider.ErrTransient},
{name: "ErrInternalError is transient", op: "get", err: libvirtErr(libvirt.ErrInternalError, "internal error: connection closed"), want: provider.ErrTransient},
{name: "unknown libvirt code is transient", op: "get", err: libvirtErr(libvirt.ErrorNumber(9999), "?"), want: provider.ErrTransient},
{name: "wrapped libvirt error keeps its code", op: "get", err: fmt.Errorf("looking up domain: %w", libvirtErr(libvirt.ErrNoDomain, "Domain not found")), want: provider.ErrNotFound},
{name: "knownhosts unknown host", op: "get", err: &knownhosts.KeyError{}, want: provider.ErrPermanent},
{name: "knownhosts mismatch", op: "get", err: &knownhosts.KeyError{Want: []knownhosts.KnownKey{{Filename: "/kh", Line: 1, Key: testPublicKey(t)}}}, want: provider.ErrPermanent},
{name: "wrapped knownhosts error", op: "create", err: fmt.Errorf("dialing beelink: %w", &knownhosts.KeyError{}), want: provider.ErrPermanent},
{name: "ssh unable to authenticate", op: "create", err: errors.New(sshAuthFailedMsg), want: provider.ErrPermanent},
{name: "wrapped ssh unable to authenticate", op: "list", err: fmt.Errorf("dialing beelink: %w", errors.New(sshAuthFailedMsg)), want: provider.ErrPermanent},
{name: "missing key file is transient", op: "create", err: fmt.Errorf("reading ssh key file /k: %w", fs.ErrNotExist), want: provider.ErrTransient},
{name: "os.PathError not exist is transient", op: "create", err: &os.PathError{Op: "open", Path: "/k", Err: fs.ErrNotExist}, want: provider.ErrTransient},
{name: "context canceled", op: "get", err: context.Canceled, want: provider.ErrTransient},
{name: "context deadline", op: "get", err: context.DeadlineExceeded, want: provider.ErrTransient},
{name: "net.OpError", op: "get", err: &net.OpError{Op: "dial", Net: "tcp", Err: errors.New("connection refused")}, want: provider.ErrTransient},
{name: "io.EOF", op: "list", err: io.EOF, want: provider.ErrTransient},
{name: "io.ErrUnexpectedEOF", op: "list", err: io.ErrUnexpectedEOF, want: provider.ErrTransient},
{name: "anything else is transient", op: "get", err: errors.New("anything"), want: provider.ErrTransient},
{name: "pre-classified NotFound stays", op: "create", err: fmt.Errorf("x: %w", provider.ErrNotFound), want: provider.ErrNotFound},
{name: "pre-classified Quota stays", op: "create", err: fmt.Errorf("x: %w", provider.ErrQuotaExceeded), want: provider.ErrQuotaExceeded},
{name: "pre-classified Permanent stays", op: "get", err: fmt.Errorf("x: %w", permanentf("bad %s", "thing")), want: provider.ErrPermanent},
{name: "pre-classified Transient stays", op: "get", err: fmt.Errorf("x: %w", transientf("blip %d", 1)), want: provider.ErrTransient},
{name: "pre-classified beats a libvirt code underneath", op: "get", err: fmt.Errorf("%w: %w", provider.ErrPermanent, libvirtErr(libvirt.ErrNoDomain, "x")), want: provider.ErrPermanent},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := classify(tc.op, tc.err); !errors.Is(got, tc.want) {
t.Errorf("classify(%q, %v) = %v, want %v", tc.op, tc.err, got, tc.want)
}
})
}
}
func TestHasCode(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
code libvirt.ErrorNumber
want bool
}{
{name: "matching code", err: libvirtErr(libvirt.ErrNoDomain, "x"), code: libvirt.ErrNoDomain, want: true},
{name: "matching code wrapped", err: fmt.Errorf("w: %w", libvirtErr(libvirt.ErrStorageVolExist, "x")), code: libvirt.ErrStorageVolExist, want: true},
{name: "other code", err: libvirtErr(libvirt.ErrNoDomain, "x"), code: libvirt.ErrNoStorageVol, want: false},
{name: "not a libvirt error", err: errors.New("x"), code: libvirt.ErrNoDomain, want: false},
{name: "nil", err: nil, code: libvirt.ErrNoDomain, want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := hasCode(tc.err, tc.code); got != tc.want {
t.Errorf("hasCode(%v, %v) = %v, want %v", tc.err, tc.code, got, tc.want)
}
})
}
}
func TestLibvirtErrorFields(t *testing.T) {
t.Parallel()
got := libvirtErrorFields(fmt.Errorf("w: %w", libvirtErr(libvirt.ErrNoDomain, "x")))
want := []any{"libvirtCode", 42, "libvirtError", "ErrNoDomain"}
if len(got) != len(want) {
t.Fatalf("libvirtErrorFields() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("libvirtErrorFields()[%d] = %v (%T), want %v (%T)", i, got[i], got[i], want[i], want[i])
}
}
if fields := libvirtErrorFields(errors.New("x")); fields != nil {
t.Errorf("libvirtErrorFields(non-libvirt) = %v, want nil", fields)
}
if fields := libvirtErrorFields(libvirtErr(libvirt.ErrorNumber(9999), "x")); fields[3] != "ErrorNumber(9999)" {
t.Errorf("libvirtErrorFields(unknown code)[3] = %v, want ErrorNumber(9999)", fields[3])
}
}
func TestPermanentfAndTransientf(t *testing.T) {
t.Parallel()
perm := permanentf("host %q missing", "x")
if !errors.Is(perm, provider.ErrPermanent) || perm.Error() != `provider: permanent error: host "x" missing` {
t.Errorf("permanentf() = %q, want ErrPermanent with the message", perm)
}
trans := transientf("retry %d", 3)
if !errors.Is(trans, provider.ErrTransient) || trans.Error() != "provider: transient error: retry 3" {
t.Errorf("transientf() = %q, want ErrTransient with the message", trans)
}
}
// testPublicKey parses a fixed ed25519 public key so a knownhosts.KeyError
// with a Want entry can be built without generating keys.
func testPublicKey(t *testing.T) ssh.PublicKey {
t.Helper()
const line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGcZBhnMPTyDwFn9lBhwIutqLb2bSlhcZoqpuWv7VuKI test"
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
if err != nil {
t.Fatalf("ParseAuthorizedKey: %v", err)
}
return key
}
+28
View File
@@ -0,0 +1,28 @@
package kvm2
import (
"strings"
)
// The providerID is hosts/<host>/domains/<name>. Host names are validated at
// config load to contain no slash, and domain names come from NameFromUID,
// so a strict four-segment split is enough to parse it.
const (
idHostsSegment = "hosts"
idDomainsSegment = "domains"
)
func formatProviderID(host, name string) string {
return idHostsSegment + "/" + host + "/" + idDomainsSegment + "/" + name
}
// parseProviderID splits an ID back into host and domain name. Anything
// that is not exactly hosts/<host>/domains/<name> with both non-empty is
// ErrPermanent: no retry will make it resolve.
func parseProviderID(id string) (host, name string, err error) {
parts := strings.Split(id, "/")
if len(parts) != 4 || parts[0] != idHostsSegment || parts[2] != idDomainsSegment || parts[1] == "" || parts[3] == "" {
return "", "", permanentf("malformed providerID %q: want hosts/<host>/domains/<name>", id)
}
return parts[1], parts[3], nil
}
+54
View File
@@ -0,0 +1,54 @@
package kvm2
import (
"errors"
"strings"
"testing"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
func TestFormatProviderID_roundTrip(t *testing.T) {
t.Parallel()
id := formatProviderID("beelink", "proxy-abc123def456ghij")
if id != "hosts/beelink/domains/proxy-abc123def456ghij" {
t.Fatalf("formatProviderID() = %q", id)
}
host, name, err := parseProviderID(id)
if err != nil || host != "beelink" || name != "proxy-abc123def456ghij" {
t.Errorf("parseProviderID(%q) = %q, %q, %v; want beelink, proxy-abc123def456ghij, nil", id, host, name, err)
}
}
func TestParseProviderID_malformed(t *testing.T) {
t.Parallel()
tests := []struct {
name string
id string
}{
{name: "empty", id: ""},
{name: "two segments", id: "hosts/h"},
{name: "three segments", id: "hosts/h/domains"},
{name: "empty host", id: "hosts//domains/n"},
{name: "empty name", id: "hosts/h/domains/"},
{name: "wrong first literal", id: "nodes/h/domains/n"},
{name: "wrong third literal", id: "hosts/h/vms/n"},
{name: "extra segment", id: "hosts/h/domains/n/extra"},
{name: "slash in name", id: "hosts/h/domains/a/b"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
host, name, err := parseProviderID(tc.id)
if err == nil {
t.Fatalf("parseProviderID(%q) = %q, %q, nil; want error", tc.id, host, name)
}
if !errors.Is(err, provider.ErrPermanent) {
t.Errorf("parseProviderID(%q) error = %v, want ErrPermanent", tc.id, err)
}
if !strings.Contains(err.Error(), tc.id) {
t.Errorf("parseProviderID(%q) error = %q, want it to name the ID", tc.id, err)
}
})
}
}
+143
View File
@@ -0,0 +1,143 @@
// Package kvm2 implements the provider contract on libvirt/KVM hypervisors
// reached over SSH: the host's libvirt unix socket is forwarded through the
// SSH connection and driven with go-libvirt's RPC client. The design is in
// docs/kvm2-provider-spec.md.
package kvm2
import (
"context"
"errors"
"os"
"time"
"github.com/go-logr/logr"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
const (
defaultSocket = "/var/run/libvirt/libvirt-sock"
defaultPool = "default"
defaultDiskSizeGB = 10
)
// Provider is one configured kvm2 entry: a set of hosts sharing one SSH key
// and one image and machine-type catalogue.
type Provider struct {
name string
cfg provider.KVMConfig
now func() time.Time
}
var _ provider.Provider = (*Provider)(nil)
// New builds a Provider from pc.KVM2. It never dials a host: the credential
// files are only checked for existence, and a missing one is a warning, not
// an error, because the Secret mount is optional and may land later.
func New(ctx context.Context, pc provider.ProviderConfig) (provider.Provider, error) {
cfg := provider.KVMConfig{}
if pc.KVM2 != nil {
cfg = *pc.KVM2
}
p := newProvider(pc.Name, cfg)
log := p.logger(ctx)
if _, err := os.Stat(p.cfg.SSHKeyFile); err != nil {
log.Info("WARNING: ssh key file is not readable; every call fails until it is", "path", p.cfg.SSHKeyFile, "reason", err.Error())
}
switch {
case p.cfg.InsecureIgnoreHostKey:
log.Info("WARNING: insecureIgnoreHostKey is set; host keys are not verified")
default:
if _, err := os.Stat(p.cfg.KnownHostsFile); err != nil {
log.Info("WARNING: known_hosts file is not readable; every call fails until it is", "path", p.cfg.KnownHostsFile, "reason", err.Error())
}
}
return p, nil
}
func newProvider(name string, cfg provider.KVMConfig) *Provider {
return &Provider{name: name, cfg: withDefaults(cfg), now: time.Now}
}
// withDefaults returns cfg with every optional field filled in, so the rest
// of the package never re-checks for an empty value. The hosts and images
// are copied, not shared with the caller.
func withDefaults(cfg provider.KVMConfig) provider.KVMConfig {
if cfg.DiskSizeGB == 0 {
cfg.DiskSizeGB = defaultDiskSizeGB
}
hosts := make([]provider.KVMHost, len(cfg.Hosts))
for i, h := range cfg.Hosts {
if h.Socket == "" {
h.Socket = defaultSocket
}
if h.Pool == "" {
h.Pool = defaultPool
}
hosts[i] = h
}
cfg.Hosts = hosts
images := make(map[string]provider.KVMImage, len(cfg.Images))
for k, img := range cfg.Images {
if img.Format == "" {
img.Format = provider.KVMFormatQcow2
}
if img.Firmware == "" {
img.Firmware = provider.KVMFirmwareUEFI
}
images[k] = img
}
cfg.Images = images
return cfg
}
func (p *Provider) logger(ctx context.Context) logr.Logger {
return logf.FromContext(ctx).WithName("kvm2").WithValues("provider", p.name)
}
// host resolves a placement.zone or a providerID's host segment to a
// configured host. An empty zone is allowed only when exactly one host is
// configured.
func (p *Provider) host(zone string) (provider.KVMHost, error) {
if zone == "" {
if len(p.cfg.Hosts) == 1 {
return p.cfg.Hosts[0], nil
}
return provider.KVMHost{}, permanentf("placement.zone is required when kvm2.hosts lists more than one host")
}
for _, h := range p.cfg.Hosts {
if h.Name == zone {
return h, nil
}
}
return provider.KVMHost{}, permanentf("host %q is not in kvm2.hosts", zone)
}
// wrapErr is the single exit for errors: it classifies err and wraps it with
// the configured provider name, op and id.
func (p *Provider) wrapErr(op, id string, err error) error {
return provider.Wrap(classify(op, err), op, p.name, id, err)
}
var errNotImplemented = errors.New("kvm2: not implemented")
// Create is not implemented yet.
func (p *Provider) Create(_ context.Context, _ provider.CreateRequest) (string, error) {
return "", provider.Wrap(provider.ErrPermanent, "create", p.name, "", errNotImplemented)
}
// Get is not implemented yet.
func (p *Provider) Get(_ context.Context, _ string) (*provider.Instance, error) {
return nil, provider.Wrap(provider.ErrPermanent, "get", p.name, "", errNotImplemented)
}
// Delete is not implemented yet.
func (p *Provider) Delete(_ context.Context, _ string) error {
return provider.Wrap(provider.ErrPermanent, "delete", p.name, "", errNotImplemented)
}
// ListByTag is not implemented yet.
func (p *Provider) ListByTag(_ context.Context) ([]provider.Instance, error) {
return nil, provider.Wrap(provider.ErrPermanent, "list", p.name, "", errNotImplemented)
}
+285
View File
@@ -0,0 +1,285 @@
package kvm2
import (
"context"
"errors"
"strings"
"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"
)
func captureContext() (context.Context, *[]string) {
lines := &[]string{}
log := funcr.New(func(prefix, args string) {
*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})
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 TestWrapErr_carriesProviderNameOpAndID(t *testing.T) {
t.Parallel()
p := newProvider("kvm2-home", twoHosts())
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)
}
}
func TestStubs_returnPermanentWithProviderName(t *testing.T) {
t.Parallel()
ctx := context.Background()
p := newProvider("kvm2-home", twoHosts())
_, createErr := p.Create(ctx, provider.CreateRequest{Name: "proxy-x"})
inst, getErr := p.Get(ctx, "hosts/beelink/domains/proxy-x")
deleteErr := p.Delete(ctx, "hosts/beelink/domains/proxy-x")
list, listErr := p.ListByTag(ctx)
if inst != nil || list != nil {
t.Errorf("Get = %+v, ListByTag = %+v, want nil results from stubs", inst, list)
}
for op, err := range map[string]error{"create": createErr, "get": getErr, "delete": deleteErr, "list": listErr} {
var pe *provider.Error
if !errors.As(err, &pe) {
t.Errorf("%s: error = %T, want *provider.Error", op, err)
continue
}
if pe.Provider != "kvm2-home" || pe.Op != op || !errors.Is(err, provider.ErrPermanent) {
t.Errorf("%s: error = %+v, want provider kvm2-home, op %s, ErrPermanent", op, pe, op)
}
}
}
+49
View File
@@ -0,0 +1,49 @@
package kvm2
import (
"net/netip"
"github.com/digitalocean/go-libvirt"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// mapState turns a libvirt domain state plus the discovered address into the
// contract's state. nostate means libvirt could not tell, so the next poll
// decides; an active domain is Running only once it has an address; every
// inactive state, and anything unknown, is Stopped, which the reconciler
// answers by replacing the instance.
func mapState(s libvirt.DomainState, ip string) provider.InstanceState {
switch {
case s == libvirt.DomainNostate:
return provider.StateProvisioning
case isActive(s) && ip == "":
return provider.StateProvisioning
case isActive(s):
return provider.StateRunning
default:
return provider.StateStopped
}
}
func isActive(s libvirt.DomainState) bool {
return s == libvirt.DomainRunning || s == libvirt.DomainBlocked
}
// firstIPv4 picks the first usable IPv4 address from the guest's interfaces:
// not loopback, not link-local, not unspecified. Empty means no address yet.
func firstIPv4(ifaces []libvirt.DomainInterface) string {
for _, iface := range ifaces {
for _, a := range iface.Addrs {
if libvirt.IPAddrType(a.Type) != libvirt.IPAddrTypeIpv4 {
continue
}
addr, err := netip.ParseAddr(a.Addr)
if err != nil || !addr.Is4() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsUnspecified() {
continue
}
return addr.String()
}
}
return ""
}
+92
View File
@@ -0,0 +1,92 @@
package kvm2
import (
"testing"
"github.com/digitalocean/go-libvirt"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
func TestMapState_table(t *testing.T) {
t.Parallel()
tests := []struct {
state libvirt.DomainState
noIP provider.InstanceState
withIP provider.InstanceState
display string
}{
{libvirt.DomainNostate, provider.StateProvisioning, provider.StateProvisioning, "nostate"},
{libvirt.DomainRunning, provider.StateProvisioning, provider.StateRunning, "running"},
{libvirt.DomainBlocked, provider.StateProvisioning, provider.StateRunning, "blocked"},
{libvirt.DomainPaused, provider.StateStopped, provider.StateStopped, "paused"},
{libvirt.DomainShutdown, provider.StateStopped, provider.StateStopped, "shutdown"},
{libvirt.DomainShutoff, provider.StateStopped, provider.StateStopped, "shutoff"},
{libvirt.DomainCrashed, provider.StateStopped, provider.StateStopped, "crashed"},
{libvirt.DomainPmsuspended, provider.StateStopped, provider.StateStopped, "pmsuspended"},
{libvirt.DomainState(99), provider.StateStopped, provider.StateStopped, "unknown code 99"},
}
for _, tc := range tests {
t.Run(tc.display, func(t *testing.T) {
t.Parallel()
if got := mapState(tc.state, ""); got != tc.noIP {
t.Errorf("mapState(%s, \"\") = %s, want %s", tc.display, got, tc.noIP)
}
if got := mapState(tc.state, "10.0.0.5"); got != tc.withIP {
t.Errorf("mapState(%s, ip) = %s, want %s", tc.display, got, tc.withIP)
}
})
}
}
func TestIsActive(t *testing.T) {
t.Parallel()
for s, want := range map[libvirt.DomainState]bool{
libvirt.DomainRunning: true, libvirt.DomainBlocked: true,
libvirt.DomainNostate: false, libvirt.DomainPaused: false, libvirt.DomainShutoff: false, libvirt.DomainState(99): false,
} {
if got := isActive(s); got != want {
t.Errorf("isActive(%d) = %v, want %v", s, got, want)
}
}
}
func addr(typ libvirt.IPAddrType, a string) libvirt.DomainIPAddr {
return libvirt.DomainIPAddr{Type: int32(typ), Addr: a}
}
func TestFirstIPv4_filters(t *testing.T) {
t.Parallel()
v4 := libvirt.IPAddrTypeIpv4
v6 := libvirt.IPAddrTypeIpv6
tests := []struct {
name string
ifaces []libvirt.DomainInterface
want string
}{
{name: "empty list", ifaces: nil, want: ""},
{name: "loopback only", ifaces: []libvirt.DomainInterface{{Name: "lo", Addrs: []libvirt.DomainIPAddr{addr(v4, "127.0.0.1")}}}, want: ""},
{name: "link-local only", ifaces: []libvirt.DomainInterface{{Name: "eth0", Addrs: []libvirt.DomainIPAddr{addr(v4, "169.254.10.1")}}}, want: ""},
{name: "ipv6 only", ifaces: []libvirt.DomainInterface{{Name: "eth0", Addrs: []libvirt.DomainIPAddr{addr(v6, "2001:db8::1"), addr(v6, "fe80::1")}}}, want: ""},
{name: "unspecified", ifaces: []libvirt.DomainInterface{{Name: "eth0", Addrs: []libvirt.DomainIPAddr{addr(v4, "0.0.0.0")}}}, want: ""},
{name: "unparsable", ifaces: []libvirt.DomainInterface{{Name: "eth0", Addrs: []libvirt.DomainIPAddr{addr(v4, "not-an-ip")}}}, want: ""},
{name: "ipv4 literal tagged as ipv6 is skipped", ifaces: []libvirt.DomainInterface{{Name: "eth0", Addrs: []libvirt.DomainIPAddr{addr(v6, "10.0.0.7")}}}, want: ""},
{name: "ipv6 literal tagged as ipv4 is skipped", ifaces: []libvirt.DomainInterface{{Name: "eth0", Addrs: []libvirt.DomainIPAddr{addr(v4, "2001:db8::1")}}}, want: ""},
{
name: "good one after bad ones across interfaces",
ifaces: []libvirt.DomainInterface{
{Name: "lo", Addrs: []libvirt.DomainIPAddr{addr(v4, "127.0.0.1")}},
{Name: "eth0", Addrs: []libvirt.DomainIPAddr{addr(v6, "fe80::5054:ff:fe12:3456"), addr(v4, "169.254.1.1"), addr(v4, "bogus"), addr(v4, "192.168.1.42"), addr(v4, "192.168.1.43")}},
},
want: "192.168.1.42",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := firstIPv4(tc.ifaces); got != tc.want {
t.Errorf("firstIPv4() = %q, want %q", got, tc.want)
}
})
}
}