Each configured host is checked concurrently over the pooled SSH+libvirt connection: libvirt version and hostname prove the daemon answers, then the configured pool must exist and be running, with its free space reported. Dial failures are now tagged with their stage (SSH material, TCP, SSH handshake, libvirt connect) by a dialError that keeps the message and unwraps to the cause, so classification is unchanged and the check can say why a host is unreachable. A pool verdict is decided after the call returns so it never drops a working connection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
271 lines
8.3 KiB
Go
271 lines
8.3 KiB
Go
package kvm
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
libvirt "github.com/digitalocean/go-libvirt"
|
|
|
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
|
)
|
|
|
|
// withoutLatency zeroes the one field a test cannot predict.
|
|
func withoutLatency(in []provider.TargetStatus) []provider.TargetStatus {
|
|
out := make([]provider.TargetStatus, len(in))
|
|
for i, ts := range in {
|
|
ts.Latency = 0
|
|
out[i] = ts
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestCheck_reachable(t *testing.T) {
|
|
t.Parallel()
|
|
api := newFakeAPI("default")
|
|
api.poolCapacity = 10 << 30
|
|
api.pools["default"]["disk.qcow2"] = &fakeVol{capacity: 4 << 30}
|
|
p := newTestProvider(hostFakes{"nas1": api}, "nas1")
|
|
|
|
got := withoutLatency(p.Check(context.Background()))
|
|
|
|
want := provider.TargetStatus{
|
|
Name: "nas1",
|
|
Address: "nas1:22",
|
|
Ready: true,
|
|
Reason: provider.ReasonReachable,
|
|
Details: map[string]string{
|
|
"user": "virt",
|
|
"pool": "default",
|
|
"hostname": "fake-host",
|
|
"libvirtVersion": "10.2.0",
|
|
"poolState": "running",
|
|
"poolCapacity": "10.0GiB",
|
|
"poolAvailable": "6.0GiB",
|
|
},
|
|
}
|
|
if len(got) != 1 {
|
|
t.Fatalf("Check() returned %d targets, want 1", len(got))
|
|
}
|
|
if got[0].Name != want.Name || got[0].Address != want.Address || got[0].Ready != want.Ready ||
|
|
got[0].Reason != want.Reason || got[0].Message != "" || !maps.Equal(got[0].Details, want.Details) {
|
|
t.Errorf("Check() = %+v, want %+v", got[0], want)
|
|
}
|
|
}
|
|
|
|
func TestCheck_perHostVerdicts(t *testing.T) {
|
|
t.Parallel()
|
|
p := newTestProvider(hostFakes{"nas1": newFakeAPI("default"), "nas3": newFakeAPI("default")}, "nas1", "nas2", "nas3")
|
|
|
|
got := p.Check(context.Background())
|
|
|
|
if len(got) != 3 {
|
|
t.Fatalf("Check() returned %d targets, want 3", len(got))
|
|
}
|
|
for i, want := range []struct {
|
|
name string
|
|
ready bool
|
|
}{{"nas1", true}, {"nas2", false}, {"nas3", true}} {
|
|
if got[i].Name != want.name || got[i].Ready != want.ready {
|
|
t.Errorf("target %d = %s ready=%v, want %s ready=%v", i, got[i].Name, got[i].Ready, want.name, want.ready)
|
|
}
|
|
}
|
|
if got[1].Reason != provider.ReasonUnreachable || !strings.Contains(got[1].Message, errHostOff.Error()) {
|
|
t.Errorf("unreachable host = %s %q, want Unreachable carrying %q", got[1].Reason, got[1].Message, errHostOff)
|
|
}
|
|
if got[1].Details["pool"] != "default" || got[1].Details["hostname"] != "" {
|
|
t.Errorf("unreachable host details = %v, want the configured pool and nothing learned", got[1].Details)
|
|
}
|
|
}
|
|
|
|
// TestCheck_libvirtVerdicts also checks twice per case: none of these
|
|
// verdicts is evidence against the link, so the connection must survive.
|
|
func TestCheck_libvirtVerdicts(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
setup func(api *fakeAPI)
|
|
pools []string
|
|
wantReason string
|
|
wantMessage string
|
|
}{
|
|
{
|
|
name: "pool inactive",
|
|
pools: []string{"default"},
|
|
setup: func(api *fakeAPI) { api.poolStates["default"] = libvirt.StoragePoolInactive },
|
|
wantReason: reasonPoolNotActive,
|
|
wantMessage: `storage pool "default" is inactive`,
|
|
},
|
|
{
|
|
name: "pool missing",
|
|
pools: []string{"other"},
|
|
wantReason: reasonPoolNotFound,
|
|
wantMessage: "no storage pool with matching name 'default'",
|
|
},
|
|
{
|
|
name: "daemon error",
|
|
pools: []string{"default"},
|
|
setup: func(api *fakeAPI) { api.fail["ConnectGetHostname"] = lvError(libvirt.ErrInternalError, "boom") },
|
|
wantReason: reasonLibvirtError,
|
|
wantMessage: "boom",
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
api := newFakeAPI(tc.pools...)
|
|
if tc.setup != nil {
|
|
tc.setup(api)
|
|
}
|
|
var dials atomic.Int32
|
|
dial := func(ctx context.Context, h provider.KVMHost) (*hostClient, error) {
|
|
dials.Add(1)
|
|
return hostFakes{"nas1": api}.dial(ctx, h)
|
|
}
|
|
pc := testConfig("nas1")
|
|
p := newWithDialer(pc.Name, *pc.KVM, dial, nil)
|
|
|
|
for range 2 {
|
|
got := p.Check(context.Background())
|
|
if got[0].Ready || got[0].Reason != tc.wantReason || !strings.Contains(got[0].Message, tc.wantMessage) {
|
|
t.Errorf("Check() = %+v, want not ready, %s, message containing %q", got[0], tc.wantReason, tc.wantMessage)
|
|
}
|
|
}
|
|
if n := dials.Load(); n != 1 {
|
|
t.Errorf("dialed %d times over two checks, want 1", n)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheck_hostsCheckedConcurrently(t *testing.T) {
|
|
t.Parallel()
|
|
nas2Dialing := make(chan struct{})
|
|
dial := func(ctx context.Context, h provider.KVMHost) (*hostClient, error) {
|
|
switch h.Name {
|
|
case "nas1":
|
|
select {
|
|
case <-nas2Dialing:
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
case "nas2":
|
|
close(nas2Dialing)
|
|
}
|
|
return &hostClient{api: newFakeAPI("default"), close: func() {}}, nil
|
|
}
|
|
pc := testConfig("nas1", "nas2")
|
|
p := newWithDialer(pc.Name, *pc.KVM, dial, nil)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
got := p.Check(ctx)
|
|
|
|
for _, ts := range got {
|
|
if !ts.Ready {
|
|
t.Errorf("%s = %s %q, want ready: a sequential check would time out nas1", ts.Name, ts.Reason, ts.Message)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheck_timeout(t *testing.T) {
|
|
t.Parallel()
|
|
api := newFakeAPI("default")
|
|
block := make(chan struct{})
|
|
t.Cleanup(func() { close(block) })
|
|
blocked := &blockingAPI{fakeAPI: api, block: block}
|
|
dial := func(context.Context, provider.KVMHost) (*hostClient, error) {
|
|
return &hostClient{api: blocked, close: func() {}}, nil
|
|
}
|
|
pc := testConfig("nas1")
|
|
p := newWithDialer(pc.Name, *pc.KVM, dial, nil)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
|
|
got := p.Check(ctx)
|
|
|
|
if got[0].Ready || got[0].Reason != provider.ReasonTimeout {
|
|
t.Errorf("Check() = %+v, want Timeout", got[0])
|
|
}
|
|
}
|
|
|
|
// blockingAPI hangs in ConnectGetLibVersion until block closes, like a
|
|
// daemon that stopped answering.
|
|
type blockingAPI struct {
|
|
*fakeAPI
|
|
block chan struct{}
|
|
}
|
|
|
|
func (b *blockingAPI) ConnectGetLibVersion() (uint64, error) {
|
|
<-b.block
|
|
return 0, io.EOF
|
|
}
|
|
|
|
func TestCheckReason(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want string
|
|
}{
|
|
{"ssh material", &dialError{stage: stageSSHMaterial, err: fmt.Errorf("%w: parsing key", provider.ErrPermanent)}, reasonSSHMaterialUnavailable},
|
|
{"tcp", &dialError{stage: stageTCP, err: errors.New("ssh dial: connection refused")}, provider.ReasonUnreachable},
|
|
{"ssh handshake", &dialError{stage: stageSSHHandshake, err: errors.New("ssh handshake: unable to authenticate")}, reasonSSHHandshakeFailed},
|
|
{"libvirt connect", &dialError{stage: stageLibvirtConnect, err: errors.New("libvirt connect via /sock: EOF")}, reasonLibvirtConnectFailed},
|
|
{"stage survives the pool's wrapping", fmt.Errorf("connecting to host nas1 (nas1:22): %w",
|
|
&dialError{stage: stageSSHHandshake, err: context.DeadlineExceeded}), reasonSSHHandshakeFailed},
|
|
{"deadline", context.DeadlineExceeded, provider.ReasonTimeout},
|
|
{"cancelled", context.Canceled, provider.ReasonTimeout},
|
|
{"no pool", lvError(libvirt.ErrNoStoragePool, "no pool"), reasonPoolNotFound},
|
|
{"other libvirt error", lvError(libvirt.ErrInternalError, "boom"), reasonLibvirtError},
|
|
{"link died mid-call", io.EOF, 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 TestDialError_keepsClassification(t *testing.T) {
|
|
t.Parallel()
|
|
err := fmt.Errorf("connecting to host nas1: %w",
|
|
&dialError{stage: stageSSHMaterial, err: fmt.Errorf("%w: parsing kvm.sshKeyFile: bad", provider.ErrPermanent)})
|
|
|
|
if got := classify(err); !errors.Is(got, provider.ErrPermanent) {
|
|
t.Errorf("classify() = %v, want ErrPermanent through the dialError", got)
|
|
}
|
|
if !isClassified(err) {
|
|
t.Error("isClassified() = false, want true")
|
|
}
|
|
if want := "connecting to host nas1: permanent: parsing kvm.sshKeyFile: bad"; !strings.HasSuffix(err.Error(), "parsing kvm.sshKeyFile: bad") {
|
|
t.Errorf("message = %q, want it unchanged, like %q", err.Error(), want)
|
|
}
|
|
}
|
|
|
|
func TestFormatLibVersion(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
in uint64
|
|
want string
|
|
}{
|
|
{10002000, "10.2.0"},
|
|
{11006001, "11.6.1"},
|
|
{9000000, "9.0.0"},
|
|
}
|
|
for _, tc := range tests {
|
|
if got := formatLibVersion(tc.in); got != tc.want {
|
|
t.Errorf("formatLibVersion(%d) = %s, want %s", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|