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/gcp"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/kubernetes" "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/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/provider/registry"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/providerhealth" "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/providerhealth"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing" "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) { "gcp": func(ctx context.Context, pc provider.ProviderConfig) (provider.Provider, error) {
return gcp.NewWithWireOptions(ctx, pc, gcp.WireLogOptions{FullPayloads: gcpWireFullPayloads}) return gcp.NewWithWireOptions(ctx, pc, gcp.WireLogOptions{FullPayloads: gcpWireFullPayloads})
}, },
"kvm": kvm.New, "kvm": kvm.New,
"kvm2": kvm2.New,
}) })
if err != nil { if err != nil {
setupLog.Error(err, "Failed to build providers") 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 0 — Plan file and branch
- [x] Step 1 — The spec, `docs/kvm2-provider-spec.md` - [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 3 — Pure builders: domain and volume XML, metadata, seed
- [ ] Step 4 — Transport, seam, decorator, fakes - [ ] Step 4 — Transport, seam, decorator, fakes
- [ ] Step 5 — Image cache and pool-space precheck - [ ] 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 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 contract §11 item, so the later steps' briefs can quote it rather than
re-derive the mapping. 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"` Kubernetes *KubernetesConfig `json:"kubernetes,omitempty"`
GCP *GCPConfig `json:"gcp,omitempty"` GCP *GCPConfig `json:"gcp,omitempty"`
KVM *KVMConfig `json:"kvm,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 // 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 { if p.KVM != nil {
out = append(out, "kvm") out = append(out, "kvm")
} }
if p.KVM2 != nil {
out = append(out, "kvm2")
}
return out return out
} }
@@ -100,7 +106,7 @@ func (c *Config) validate() error {
prefix := fmt.Sprintf("providers[%d] %q", i, p.Name) prefix := fmt.Sprintf("providers[%d] %q", i, p.Name)
switch p.Type { switch p.Type {
case "kubernetes", "gcp", "kvm": case "kubernetes", "gcp", "kvm", "kvm2":
case "": case "":
return fmt.Errorf("%s: type is required", prefix) return fmt.Errorf("%s: type is required", prefix)
default: default:
@@ -120,6 +126,10 @@ func (c *Config) validate() error {
if err := validateKVM(prefix, p.KVM); err != nil { if err := validateKVM(prefix, p.KVM); err != nil {
return err return err
} }
case "kvm2":
if err := validateKVMBlock(prefix, "kvm2", p.KVM2); err != nil {
return err
}
} }
} }
return nil 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_.-]*$`) var kvmIdentifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
func validateKVM(prefix string, c *KVMConfig) error { 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 { 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 == "" { 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 { 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 { 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 { 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) { if !kvmIdentifier.MatchString(name) {
return fmt.Errorf("%s: key must be letters, digits, dots, dashes or underscores"+ return fmt.Errorf("%s: key must be letters, digits, dots, dashes or underscores"+
" — it becomes the cached volume name base-<key>.<format>", p) " — 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 { 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 { for name, mt := range c.MachineTypes {
if mt.VCPUs <= 0 || mt.MemoryMiB <= 0 { 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 { 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)) seen := make(map[string]bool, len(c.Hosts))
for i, h := range 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 == "" { if h.Name == "" {
return fmt.Errorf("%s: name is required", p) 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 { func indent(s string, n int) string {
pad := strings.Repeat(" ", n) pad := strings.Repeat(" ", n)
lines := strings.Split(strings.TrimLeft(s, "\n"), "\n") lines := strings.Split(strings.TrimLeft(s, "\n"), "\n")
+20
View File
@@ -118,6 +118,26 @@ providers:
image: custom-image`, image: custom-image`,
wantErrSub: "type is gcp but a kubernetes block is set", 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", name: "strict mode rejects unknown top-level key",
yaml: ` 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)
}
})
}
}