Shorter start wait with immediate verdicts for dead or paused domains, ListByTag without state or address RPCs, Timeout for an expired check round, no adoption of unmarked domains, reference-counted keyed locks, dead options removed, HEAD fallback on any non-2xx. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
580 lines
20 KiB
Go
580 lines
20 KiB
Go
package kvm2
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/digitalocean/go-libvirt"
|
|
"github.com/go-logr/logr/funcr"
|
|
"golang.org/x/crypto/ssh/knownhosts"
|
|
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
|
|
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
|
)
|
|
|
|
// checkContext is captureContext with a sink that tolerates the concurrent
|
|
// per-host log lines of Check.
|
|
func checkContext() (context.Context, *logCapture) {
|
|
logs := &logCapture{}
|
|
log := funcr.New(func(prefix, args string) {
|
|
logs.mu.Lock()
|
|
defer logs.mu.Unlock()
|
|
logs.lines = append(logs.lines, prefix+" "+args)
|
|
}, funcr.Options{Verbosity: 2})
|
|
return logf.IntoContext(context.Background(), log), logs
|
|
}
|
|
|
|
// checkDialer hands out a distinct fake per host name, fails the hosts in
|
|
// errs, counts every dial, and, with block set, waits for ctx to end and
|
|
// then fails the way dialSSHSocket does when the TCP connect sees the ctx
|
|
// end: a dialError at the tcp stage wrapping the ctx's error.
|
|
type checkDialer struct {
|
|
backends map[string]*fakeBackend
|
|
errs map[string]error
|
|
block bool
|
|
|
|
dials atomic.Int32
|
|
}
|
|
|
|
func (d *checkDialer) dial(ctx context.Context, host provider.KVMHost) (backend, error) {
|
|
d.dials.Add(1)
|
|
if d.block {
|
|
<-ctx.Done()
|
|
return nil, &dialError{stage: stageTCP, host: host.Name, err: fmt.Errorf("dial tcp %s: %w", host.Address, ctx.Err())}
|
|
}
|
|
if err := d.errs[host.Name]; err != nil {
|
|
return nil, err
|
|
}
|
|
b, ok := d.backends[host.Name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("checkDialer: no backend for %s", host.Name)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func threeHosts() provider.KVMConfig {
|
|
cfg := twoHosts()
|
|
cfg.Hosts = append(cfg.Hosts, provider.KVMHost{Name: "lab3", Address: "lab3:22", User: "ops", Bridge: "br0", Pool: "ssd"})
|
|
return cfg
|
|
}
|
|
|
|
func fakesFor(cfg provider.KVMConfig) map[string]*fakeBackend {
|
|
out := make(map[string]*fakeBackend, len(cfg.Hosts))
|
|
for _, h := range cfg.Hosts {
|
|
f := newFakeBackend()
|
|
f.hostname = h.Name + ".home.lan"
|
|
if h.Pool != "" {
|
|
f.pools[h.Pool] = newFakePool()
|
|
}
|
|
out[h.Name] = f
|
|
}
|
|
return out
|
|
}
|
|
|
|
func detailKeys(d map[string]string) []string {
|
|
keys := make([]string, 0, len(d))
|
|
for k := range d {
|
|
keys = append(keys, k)
|
|
}
|
|
slices.Sort(keys)
|
|
return keys
|
|
}
|
|
|
|
func TestCheck_oneEntryPerHostInConfigOrder(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := threeHosts()
|
|
d := &checkDialer{backends: fakesFor(cfg), errs: map[string]error{"lab2": errors.New("dial tcp 192.0.2.2:22: connect: no route to host")}}
|
|
p := newProvider(testProvider, cfg, d)
|
|
ctx, _ := checkContext()
|
|
got := p.Check(ctx)
|
|
if len(got) != len(cfg.Hosts) {
|
|
t.Fatalf("Check() returned %d targets, want %d", len(got), len(cfg.Hosts))
|
|
}
|
|
for i, h := range cfg.Hosts {
|
|
ts := got[i]
|
|
if ts.Name != h.Name || ts.Address != h.Address {
|
|
t.Errorf("target %d = %s (%s), want %s (%s)", i, ts.Name, ts.Address, h.Name, h.Address)
|
|
}
|
|
if ts.Name == "" || ts.Address == "" {
|
|
t.Errorf("target %d has an empty name or address: %+v", i, ts)
|
|
}
|
|
wantReady := h.Name != "lab2"
|
|
if ts.Ready != wantReady {
|
|
t.Errorf("target %s ready = %v, want %v (%s: %s)", ts.Name, ts.Ready, wantReady, ts.Reason, ts.Message)
|
|
}
|
|
}
|
|
if got[1].Reason != provider.ReasonUnreachable || got[0].Reason != provider.ReasonReachable {
|
|
t.Errorf("reasons = %s, %s, %s", got[0].Reason, got[1].Reason, got[2].Reason)
|
|
}
|
|
}
|
|
|
|
func TestCheck_hostsCheckedConcurrently(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := twoHosts()
|
|
fakes := fakesFor(cfg)
|
|
for _, f := range fakes {
|
|
release := f.blockOn("ConnectGetLibVersion")
|
|
time.AfterFunc(200*time.Millisecond, func() { close(release) })
|
|
}
|
|
p := newProvider(testProvider, cfg, &checkDialer{backends: fakes})
|
|
ctx, _ := checkContext()
|
|
start := time.Now()
|
|
got := p.Check(ctx)
|
|
elapsed := time.Since(start)
|
|
if elapsed > 350*time.Millisecond {
|
|
t.Errorf("Check() took %v, want under 350ms for two 200ms hosts", elapsed)
|
|
}
|
|
for _, ts := range got {
|
|
if !ts.Ready {
|
|
t.Errorf("target %s not ready: %s: %s", ts.Name, ts.Reason, ts.Message)
|
|
}
|
|
if ts.Latency < 200*time.Millisecond {
|
|
t.Errorf("target %s latency = %v, want at least the 200ms block", ts.Name, ts.Latency)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCheck_expiredContextReturnsPromptly drives the production shape of a
|
|
// round that ran out of time during the dial: the dialer fails at the tcp
|
|
// stage wrapping context.DeadlineExceeded, which checkReason alone would
|
|
// call Unreachable; the verdict must still be Timeout because the round's
|
|
// ctx ended.
|
|
func TestCheck_expiredContextReturnsPromptly(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := twoHosts()
|
|
p := newProvider(testProvider, cfg, &checkDialer{block: true})
|
|
ctx, _ := checkContext()
|
|
ctx, cancel := context.WithTimeout(ctx, 0)
|
|
defer cancel()
|
|
start := time.Now()
|
|
got := p.Check(ctx)
|
|
if elapsed := time.Since(start); elapsed > time.Second {
|
|
t.Errorf("Check() took %v with an expired ctx, want under 1s", elapsed)
|
|
}
|
|
if len(got) != len(cfg.Hosts) {
|
|
t.Fatalf("Check() returned %d targets, want %d", len(got), len(cfg.Hosts))
|
|
}
|
|
for _, ts := range got {
|
|
if ts.Ready || ts.Reason != provider.ReasonTimeout {
|
|
t.Errorf("target %s = ready %v reason %s, want not ready, Timeout", ts.Name, ts.Ready, ts.Reason)
|
|
}
|
|
if !strings.Contains(ts.Message, context.DeadlineExceeded.Error()) || !strings.Contains(ts.Message, string(stageTCP)) {
|
|
t.Errorf("target %s message = %q, want the tcp stage and the deadline named", ts.Name, ts.Message)
|
|
}
|
|
}
|
|
}
|
|
|
|
// keyMaterial is the sentinel in the unusable key file of the credentials
|
|
// row; it must never reach a message.
|
|
const keyMaterial = "SENTINEL-PRIVATE-KEY-BYTES-9f3a"
|
|
|
|
func TestCheck_reasonsTable(t *testing.T) {
|
|
t.Parallel()
|
|
sshHandshake := func(cause error) error {
|
|
return &dialError{stage: stageSSH, host: testHost, err: fmt.Errorf("ssh: handshake failed: %w", cause)}
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
// dialErr builds the dial failure; nil means the dial succeeds.
|
|
dialErr func(t *testing.T) error
|
|
prep func(f *fakeBackend)
|
|
timeout time.Duration
|
|
cancel bool
|
|
|
|
wantReady bool
|
|
wantReason string
|
|
wantMessage string
|
|
}{
|
|
{
|
|
name: "credentials unusable",
|
|
dialErr: func(t *testing.T) error {
|
|
path := filepath.Join(t.TempDir(), "id_ed25519")
|
|
if err := os.WriteFile(path, []byte(keyMaterial), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := loadCredentials(provider.KVMConfig{SSHKeyFile: path, KnownHostsFile: "/nonexistent/known_hosts"})
|
|
if err == nil {
|
|
t.Fatal("loadCredentials accepted the sentinel file")
|
|
}
|
|
return &dialError{stage: stageCredentials, host: testHost, err: err}
|
|
},
|
|
wantReason: reasonCredentialsUnavailable,
|
|
},
|
|
{
|
|
name: "credentials missing",
|
|
dialErr: func(*testing.T) error {
|
|
_, err := loadCredentials(provider.KVMConfig{SSHKeyFile: "/nonexistent/kvm2/id_ed25519"})
|
|
return &dialError{stage: stageCredentials, host: testHost, err: err}
|
|
},
|
|
wantReason: reasonCredentialsUnavailable,
|
|
},
|
|
{
|
|
name: "tcp refused",
|
|
dialErr: func(*testing.T) error {
|
|
return &dialError{stage: stageTCP, host: testHost, err: errors.New("dial tcp 192.0.2.1:22: connect: connection refused")}
|
|
},
|
|
wantReason: provider.ReasonUnreachable,
|
|
},
|
|
{
|
|
name: "tcp timed out on the dial budget but ctx is fine",
|
|
dialErr: func(*testing.T) error {
|
|
return &dialError{stage: stageTCP, host: testHost, err: fmt.Errorf("dial tcp 192.0.2.1:22: %w", context.DeadlineExceeded)}
|
|
},
|
|
wantReason: provider.ReasonUnreachable,
|
|
},
|
|
{
|
|
name: "ssh host key unknown",
|
|
dialErr: func(*testing.T) error { return sshHandshake(&knownhosts.KeyError{}) },
|
|
wantReason: provider.ReasonUnauthorized,
|
|
},
|
|
{
|
|
name: "ssh host key mismatch",
|
|
dialErr: func(*testing.T) error {
|
|
return sshHandshake(&knownhosts.KeyError{Want: []knownhosts.KnownKey{{Filename: "known_hosts", Line: 3}}})
|
|
},
|
|
wantReason: provider.ReasonUnauthorized,
|
|
},
|
|
{
|
|
name: "ssh authentication refused",
|
|
dialErr: func(*testing.T) error {
|
|
return sshHandshake(errors.New("ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain"))
|
|
},
|
|
wantReason: provider.ReasonUnauthorized,
|
|
},
|
|
{
|
|
name: "ssh handshake reset",
|
|
dialErr: func(*testing.T) error { return sshHandshake(errors.New("read tcp: connection reset by peer")) },
|
|
wantReason: provider.ReasonUnreachable,
|
|
},
|
|
{
|
|
name: "ssh handshake timed out on the dial budget but ctx is fine",
|
|
dialErr: func(*testing.T) error { return sshHandshake(context.DeadlineExceeded) },
|
|
wantReason: provider.ReasonUnreachable,
|
|
},
|
|
{
|
|
name: "socket refused",
|
|
dialErr: func(*testing.T) error {
|
|
return &dialError{stage: stageSocket, host: testHost, err: errors.New("opening /var/run/libvirt/libvirt-sock: ssh: rejected: connect failed (No such file or directory)")}
|
|
},
|
|
wantReason: provider.ReasonUnreachable,
|
|
wantMessage: "/var/run/libvirt/libvirt-sock",
|
|
},
|
|
{
|
|
name: "libvirt access denied",
|
|
prep: func(f *fakeBackend) {
|
|
f.failWith("ConnectGetLibVersion", fakeErr(libvirt.ErrAccessDenied, "access denied"))
|
|
},
|
|
wantReason: provider.ReasonForbidden,
|
|
},
|
|
{
|
|
name: "libvirt auth failed",
|
|
prep: func(f *fakeBackend) {
|
|
f.failWith("ConnectGetHostname", fakeErr(libvirt.ErrAuthFailed, "authentication failed"))
|
|
},
|
|
wantReason: provider.ReasonForbidden,
|
|
},
|
|
{
|
|
name: "pool not found",
|
|
prep: func(f *fakeBackend) {
|
|
f.mu.Lock()
|
|
delete(f.pools, fakePoolName)
|
|
f.mu.Unlock()
|
|
},
|
|
wantReason: reasonPoolNotFound,
|
|
wantMessage: fakePoolName,
|
|
},
|
|
{
|
|
name: "other libvirt error",
|
|
prep: func(f *fakeBackend) {
|
|
f.failWith("StoragePoolGetInfo", fakeErr(libvirt.ErrInternalError, "internal error: pool info"))
|
|
},
|
|
wantReason: provider.ReasonAPIError,
|
|
},
|
|
{
|
|
name: "rpc outlives the deadline",
|
|
prep: func(f *fakeBackend) { f.blockOn("ConnectGetLibVersion") },
|
|
timeout: 50 * time.Millisecond,
|
|
wantReason: provider.ReasonTimeout,
|
|
},
|
|
{
|
|
name: "ctx cancelled mid check",
|
|
prep: func(f *fakeBackend) { f.blockOn("StoragePoolLookupByName") },
|
|
cancel: true,
|
|
wantReason: provider.ReasonTimeout,
|
|
},
|
|
{
|
|
name: "link killed mid check",
|
|
prep: func(f *fakeBackend) {
|
|
f.failWith("StoragePoolLookupByName", transientf("libvirt call StoragePoolLookupByName on beelink: connection closed while waiting: keepalive: EOF"))
|
|
},
|
|
wantReason: provider.ReasonUnreachable,
|
|
},
|
|
{
|
|
name: "pool inactive",
|
|
prep: func(f *fakeBackend) { f.pools[fakePoolName].state = libvirt.StoragePoolInactive },
|
|
wantReason: reasonPoolNotActive,
|
|
wantMessage: `storage pool "default" is inactive`,
|
|
},
|
|
{
|
|
name: "ok",
|
|
wantReady: true,
|
|
wantReason: provider.ReasonReachable,
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := twoHosts()
|
|
cfg.Hosts = cfg.Hosts[:1]
|
|
f := newFakeBackend()
|
|
d := &checkDialer{backends: map[string]*fakeBackend{testHost: f}}
|
|
if tc.dialErr != nil {
|
|
d.errs = map[string]error{testHost: tc.dialErr(t)}
|
|
}
|
|
if tc.prep != nil {
|
|
tc.prep(f)
|
|
}
|
|
p := newProvider(testProvider, cfg, d)
|
|
ctx, _ := checkContext()
|
|
switch {
|
|
case tc.timeout > 0:
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, tc.timeout)
|
|
defer cancel()
|
|
case tc.cancel:
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithCancel(ctx)
|
|
time.AfterFunc(50*time.Millisecond, cancel)
|
|
}
|
|
got := p.Check(ctx)
|
|
if len(got) != 1 {
|
|
t.Fatalf("Check() returned %d targets, want 1", len(got))
|
|
}
|
|
ts := got[0]
|
|
if ts.Ready != tc.wantReady || ts.Reason != tc.wantReason {
|
|
t.Errorf("verdict = ready %v reason %s (%s), want ready %v reason %s", ts.Ready, ts.Reason, ts.Message, tc.wantReady, tc.wantReason)
|
|
}
|
|
if !tc.wantReady && ts.Message == "" {
|
|
t.Error("failed check has no message")
|
|
}
|
|
if tc.wantReady && ts.Message != "" {
|
|
t.Errorf("successful check has a message: %q", ts.Message)
|
|
}
|
|
if strings.Contains(ts.Message, keyMaterial) {
|
|
t.Errorf("message leaks the key file content: %q", ts.Message)
|
|
}
|
|
if tc.wantMessage != "" && !strings.Contains(ts.Message, tc.wantMessage) {
|
|
t.Errorf("message = %q, want it to contain %q", ts.Message, tc.wantMessage)
|
|
}
|
|
if ts.Details["user"] != "virt" || ts.Details["pool"] != "default" {
|
|
t.Errorf("details = %v, want user and pool", ts.Details)
|
|
}
|
|
if tc.wantReady {
|
|
want := map[string]string{
|
|
"user": "virt", "pool": "default", "hostname": "fake-host", "libvirtVersion": "11.6.0",
|
|
"poolState": "running", "poolCapacityGiB": "100.0", "poolAvailableGiB": "50.0",
|
|
}
|
|
for k, v := range want {
|
|
if ts.Details[k] != v {
|
|
t.Errorf("details[%s] = %q, want %q", k, ts.Details[k], v)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheck_neverUsesPooledConnection(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := threeHosts()
|
|
fakes := fakesFor(cfg)
|
|
d := &checkDialer{backends: fakes}
|
|
p := newProvider(testProvider, cfg, d)
|
|
ctx, _ := checkContext()
|
|
got := p.Check(ctx)
|
|
for _, ts := range got {
|
|
if !ts.Ready {
|
|
t.Errorf("target %s not ready: %s: %s", ts.Name, ts.Reason, ts.Message)
|
|
}
|
|
}
|
|
if n := d.dials.Load(); int(n) != len(cfg.Hosts) {
|
|
t.Errorf("dials = %d, want one fresh dial per host (%d)", n, len(cfg.Hosts))
|
|
}
|
|
p.hosts.mu.Lock()
|
|
pooled := len(p.hosts.hosts)
|
|
p.hosts.mu.Unlock()
|
|
if pooled != 0 {
|
|
t.Errorf("pooled table has %d entries after Check, want none", pooled)
|
|
}
|
|
for name, f := range fakes {
|
|
if f.closeCount() != 1 {
|
|
t.Errorf("fresh backend for %s closed %d times, want 1", name, f.closeCount())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheck_detailsKeys(t *testing.T) {
|
|
t.Parallel()
|
|
full := []string{"hostname", "libvirtVersion", "pool", "poolAvailableGiB", "poolCapacityGiB", "poolState", "user"}
|
|
tests := []struct {
|
|
name string
|
|
dialErr error
|
|
prep func(f *fakeBackend)
|
|
want []string
|
|
}{
|
|
{name: "success", want: full},
|
|
{name: "pool not active", prep: func(f *fakeBackend) { f.pools[fakePoolName].state = libvirt.StoragePoolDegraded }, want: full},
|
|
{
|
|
name: "dial failure",
|
|
dialErr: &dialError{stage: stageTCP, host: testHost, err: errors.New("dial tcp: connection refused")},
|
|
want: []string{"pool", "user"},
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := twoHosts()
|
|
cfg.Hosts = cfg.Hosts[:1]
|
|
f := newFakeBackend()
|
|
if tc.prep != nil {
|
|
tc.prep(f)
|
|
}
|
|
d := &checkDialer{backends: map[string]*fakeBackend{testHost: f}}
|
|
if tc.dialErr != nil {
|
|
d.errs = map[string]error{testHost: tc.dialErr}
|
|
}
|
|
p := newProvider(testProvider, cfg, d)
|
|
ctx, _ := checkContext()
|
|
got := p.Check(ctx)[0]
|
|
if keys := detailKeys(got.Details); !slices.Equal(keys, tc.want) {
|
|
t.Errorf("details keys = %v, want %v", keys, tc.want)
|
|
}
|
|
for k, v := range got.Details {
|
|
if v == "" {
|
|
t.Errorf("details[%s] is empty", k)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheck_logsVerdictPerHost(t *testing.T) {
|
|
t.Parallel()
|
|
cfg := twoHosts()
|
|
d := &checkDialer{backends: fakesFor(cfg), errs: map[string]error{"lab2": &dialError{stage: stageTCP, host: "lab2", err: errors.New("dial tcp: i/o timeout")}}}
|
|
p := newProvider(testProvider, cfg, d)
|
|
ctx, logs := checkContext()
|
|
p.Check(ctx)
|
|
checked := logs.matching("kvm2 host checked")
|
|
if len(checked) != 2 {
|
|
t.Fatalf("got %d verdict lines, want 2: %v", len(checked), checked)
|
|
}
|
|
for _, l := range checked {
|
|
switch {
|
|
case strings.Contains(l, `"host"="beelink"`):
|
|
if !strings.Contains(l, `"ready"=true`) || !strings.Contains(l, `"reason"="Reachable"`) || !strings.Contains(l, `"latency"=`) {
|
|
t.Errorf("beelink verdict line = %s", l)
|
|
}
|
|
case strings.Contains(l, `"host"="lab2"`):
|
|
if !strings.Contains(l, `"ready"=false`) || !strings.Contains(l, `"reason"="Unreachable"`) {
|
|
t.Errorf("lab2 verdict line = %s", l)
|
|
}
|
|
default:
|
|
t.Errorf("verdict line without a host: %s", l)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckReason_table(t *testing.T) {
|
|
t.Parallel()
|
|
dial := func(stage dialStage, cause error) error {
|
|
return fmt.Errorf("wrapped: %w", &dialError{stage: stage, host: testHost, err: cause})
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want string
|
|
}{
|
|
{name: "credentials stage", err: dial(stageCredentials, errors.New("reading ssh key file /k: no such file")), want: reasonCredentialsUnavailable},
|
|
{name: "credentials stage even when permanent", err: dial(stageCredentials, permanentf("parsing ssh key file /k: ssh: no key found")), want: reasonCredentialsUnavailable},
|
|
{name: "tcp stage", err: dial(stageTCP, errors.New("connect: connection refused")), want: provider.ReasonUnreachable},
|
|
{name: "tcp stage with a deadline", err: dial(stageTCP, context.DeadlineExceeded), want: provider.ReasonUnreachable},
|
|
{name: "ssh stage unknown host key", err: dial(stageSSH, &knownhosts.KeyError{}), want: provider.ReasonUnauthorized},
|
|
{name: "ssh stage wrapped key error", err: dial(stageSSH, fmt.Errorf("ssh: handshake failed: %w", &knownhosts.KeyError{Want: []knownhosts.KnownKey{{}}})), want: provider.ReasonUnauthorized},
|
|
{name: "ssh stage unable to authenticate", err: dial(stageSSH, errors.New("ssh: handshake failed: ssh: unable to authenticate, attempted methods [none publickey], no supported methods remain")), want: provider.ReasonUnauthorized},
|
|
{name: "ssh stage reset", err: dial(stageSSH, errors.New("read tcp: connection reset by peer")), want: provider.ReasonUnreachable},
|
|
{name: "ssh stage cancelled", err: dial(stageSSH, context.Canceled), want: provider.ReasonUnreachable},
|
|
{name: "socket stage", err: dial(stageSocket, errors.New("opening /run/libvirt/libvirt-sock: ssh: rejected")), want: provider.ReasonUnreachable},
|
|
{name: "deadline exceeded", err: fmt.Errorf("%w: libvirt call ConnectGetHostname on beelink exceeded its 30s budget: %w", provider.ErrTransient, context.DeadlineExceeded), want: provider.ReasonTimeout},
|
|
{name: "cancelled", err: fmt.Errorf("%w: libvirt call ConnectGetHostname on beelink abandoned: %w", provider.ErrTransient, context.Canceled), want: provider.ReasonTimeout},
|
|
{name: "access denied", err: fakeErr(libvirt.ErrAccessDenied, "access denied"), want: provider.ReasonForbidden},
|
|
{name: "auth failed wrapped", err: fmt.Errorf("connecting to libvirt on host beelink: %w", fakeErr(libvirt.ErrAuthFailed, "authentication failed")), want: provider.ReasonForbidden},
|
|
{name: "no storage pool", err: fakeErr(libvirt.ErrNoStoragePool, "Storage pool not found"), want: reasonPoolNotFound},
|
|
{name: "other libvirt error", err: fakeErr(libvirt.ErrInternalError, "internal error"), want: provider.ReasonAPIError},
|
|
{name: "link killed", err: transientf("libvirt call X on beelink: connection closed while waiting: keepalive: EOF"), want: provider.ReasonUnreachable},
|
|
{name: "plain error", err: errors.New("unexpected EOF"), want: provider.ReasonUnreachable},
|
|
{name: "nil", err: nil, want: provider.ReasonUnreachable},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := checkReason(tc.err); got != tc.want {
|
|
t.Errorf("checkReason(%v) = %s, want %s", tc.err, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFormatLibVersion(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
in uint64
|
|
want string
|
|
}{
|
|
{0, "0.0.0"},
|
|
{11006000, "11.6.0"},
|
|
{10000003, "10.0.3"},
|
|
{9010012, "9.10.12"},
|
|
{100999999, "100.999.999"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.want, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := formatLibVersion(tc.in); got != tc.want {
|
|
t.Errorf("formatLibVersion(%d) = %s, want %s", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPoolStateName(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
in uint8
|
|
want string
|
|
}{
|
|
{uint8(libvirt.StoragePoolInactive), "inactive"},
|
|
{uint8(libvirt.StoragePoolBuilding), "building"},
|
|
{uint8(libvirt.StoragePoolRunning), "running"},
|
|
{uint8(libvirt.StoragePoolDegraded), "degraded"},
|
|
{uint8(libvirt.StoragePoolInaccessible), "inaccessible"},
|
|
{42, "state 42"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.want, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := poolStateName(tc.in); got != tc.want {
|
|
t.Errorf("poolStateName(%d) = %s, want %s", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|