diff --git a/cmd/main.go b/cmd/main.go index acbae79..dfa8a38 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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") diff --git a/docs/plans-executions/2026-09-16-1333-kvm2-provider.md b/docs/plans-executions/2026-09-16-1333-kvm2-provider.md index ef9010e..4c18e34 100644 --- a/docs/plans-executions/2026-09-16-1333-kvm2-provider.md +++ b/docs/plans-executions/2026-09-16-1333-kvm2-provider.md @@ -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. diff --git a/internal/provider/config.go b/internal/provider/config.go index f9b81d8..07fe0cd 100644 --- a/internal/provider/config.go +++ b/internal/provider/config.go @@ -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 diff --git a/internal/provider/config_kvm.go b/internal/provider/config_kvm.go index 701a01e..95f5271 100644 --- a/internal/provider/config_kvm.go +++ b/internal/provider/config_kvm.go @@ -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-.", 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) } diff --git a/internal/provider/config_kvm_test.go b/internal/provider/config_kvm_test.go index c44ca2c..1899ac9 100644 --- a/internal/provider/config_kvm_test.go +++ b/internal/provider/config_kvm_test.go @@ -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") diff --git a/internal/provider/config_test.go b/internal/provider/config_test.go index 3b7b9ad..f82999c 100644 --- a/internal/provider/config_test.go +++ b/internal/provider/config_test.go @@ -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: ` diff --git a/internal/provider/kvm2/errors.go b/internal/provider/kvm2/errors.go new file mode 100644 index 0000000..640cf58 --- /dev/null +++ b/internal/provider/kvm2/errors.go @@ -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()} +} diff --git a/internal/provider/kvm2/errors_test.go b/internal/provider/kvm2/errors_test.go new file mode 100644 index 0000000..68e8921 --- /dev/null +++ b/internal/provider/kvm2/errors_test.go @@ -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 +} diff --git a/internal/provider/kvm2/id.go b/internal/provider/kvm2/id.go new file mode 100644 index 0000000..50c3b5c --- /dev/null +++ b/internal/provider/kvm2/id.go @@ -0,0 +1,28 @@ +package kvm2 + +import ( + "strings" +) + +// The providerID is hosts//domains/. 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//domains/ 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//domains/", id) + } + return parts[1], parts[3], nil +} diff --git a/internal/provider/kvm2/id_test.go b/internal/provider/kvm2/id_test.go new file mode 100644 index 0000000..086d0ac --- /dev/null +++ b/internal/provider/kvm2/id_test.go @@ -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) + } + }) + } +} diff --git a/internal/provider/kvm2/kvm2.go b/internal/provider/kvm2/kvm2.go new file mode 100644 index 0000000..8560d32 --- /dev/null +++ b/internal/provider/kvm2/kvm2.go @@ -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) +} diff --git a/internal/provider/kvm2/kvm2_test.go b/internal/provider/kvm2/kvm2_test.go new file mode 100644 index 0000000..ad48b37 --- /dev/null +++ b/internal/provider/kvm2/kvm2_test.go @@ -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) + } + } +} diff --git a/internal/provider/kvm2/state.go b/internal/provider/kvm2/state.go new file mode 100644 index 0000000..150fd5c --- /dev/null +++ b/internal/provider/kvm2/state.go @@ -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 "" +} diff --git a/internal/provider/kvm2/state_test.go b/internal/provider/kvm2/state_test.go new file mode 100644 index 0000000..6f9fb07 --- /dev/null +++ b/internal/provider/kvm2/state_test.go @@ -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) + } + }) + } +}