Trace every libvirt call in the KVM provider at V(1), visible in the integration test

A tracingAPI decorator wraps the 17-method libvirt seam and logs one
"libvirt call" line per RPC after it returns: method, the identifiers it
was given, curated results, duration, and on failure the error with its
libvirt code, name and message. At V(2) the same line carries the domain
and volume XML, metadata and addresses; upload bodies are never logged.
The pool applies it in call() when V(1) is enabled and logs connect,
dial-failure and drop events, taking the caller's logger explicitly like
every other helper in the package.

The integration test installs a testr logger at KVM_TEST_LOG_LEVEL
(default 1) so the trace interleaves with t.Log output, which also ends
the "log.SetLogger(...) was never called" warning. Docs gained a Logging
section and the test env var; the KVM provider execution log records run
4 (guest hang at GRUB) that prompted this.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 19:13:51 +00:00
co-authored by Claude Fable 5.1
parent aa8b36e1d0
commit f50d30e58e
13 changed files with 904 additions and 39 deletions
+98 -13
View File
@@ -18,6 +18,7 @@ import (
"time"
libvirt "github.com/digitalocean/go-libvirt"
"github.com/go-logr/logr"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
@@ -43,6 +44,8 @@ type fakeDialer struct {
err error
delay time.Duration
clients []*fakeClient
// api is what the dialled client exposes; nil for pool-only tests.
api libvirtAPI
}
func (d *fakeDialer) dial(ctx context.Context, _ provider.KVMHost) (*hostClient, error) {
@@ -61,7 +64,7 @@ func (d *fakeDialer) dial(ctx context.Context, _ provider.KVMHost) (*hostClient,
}
fc := &fakeClient{done: make(chan struct{})}
d.clients = append(d.clients, fc)
return &hostClient{api: nil, done: fc.done, close: fc.close}, nil
return &hostClient{api: d.api, done: fc.done, close: fc.close}, nil
}
func (d *fakeDialer) dialCount() int {
@@ -72,6 +75,8 @@ func (d *fakeDialer) dialCount() int {
var testHost = provider.KVMHost{Name: "nas1", Address: "nas1:22", User: "virt", Socket: defaultSocket, Pool: "default", Bridge: "br0"}
var discard = logr.Discard()
func noop(libvirtAPI) error { return nil }
func TestHostPool_call_reusesConnection(t *testing.T) {
@@ -79,7 +84,7 @@ func TestHostPool_call_reusesConnection(t *testing.T) {
d := &fakeDialer{}
p := newHostPool(d.dial)
for range 3 {
if err := p.call(context.Background(), testHost, noop); err != nil {
if err := p.call(context.Background(), discard, testHost, noop); err != nil {
t.Fatalf("call() error = %v", err)
}
}
@@ -92,11 +97,11 @@ func TestHostPool_call_redialsAfterDisconnect(t *testing.T) {
t.Parallel()
d := &fakeDialer{}
p := newHostPool(d.dial)
if err := p.call(context.Background(), testHost, noop); err != nil {
if err := p.call(context.Background(), discard, testHost, noop); err != nil {
t.Fatalf("call() error = %v", err)
}
d.clients[0].close()
if err := p.call(context.Background(), testHost, noop); err != nil {
if err := p.call(context.Background(), discard, testHost, noop); err != nil {
t.Fatalf("second call() error = %v", err)
}
if d.dialCount() != 2 {
@@ -108,14 +113,14 @@ func TestHostPool_call_transportErrorDropsConnection(t *testing.T) {
t.Parallel()
d := &fakeDialer{}
p := newHostPool(d.dial)
err := p.call(context.Background(), testHost, func(libvirtAPI) error { return io.ErrUnexpectedEOF })
err := p.call(context.Background(), discard, testHost, func(libvirtAPI) error { return io.ErrUnexpectedEOF })
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("call() error = %v, want the transport error passed through", err)
}
if !d.clients[0].closed.Load() {
t.Error("connection not closed after a transport error")
}
if err := p.call(context.Background(), testHost, noop); err != nil {
if err := p.call(context.Background(), discard, testHost, noop); err != nil {
t.Fatalf("call() after transport error = %v", err)
}
if d.dialCount() != 2 {
@@ -127,11 +132,11 @@ func TestHostPool_call_libvirtErrorKeepsConnection(t *testing.T) {
t.Parallel()
d := &fakeDialer{}
p := newHostPool(d.dial)
err := p.call(context.Background(), testHost, func(libvirtAPI) error { return lvErr(libvirt.ErrNoDomain) })
err := p.call(context.Background(), discard, testHost, func(libvirtAPI) error { return lvErr(libvirt.ErrNoDomain) })
if !hasCode(err, libvirt.ErrNoDomain) {
t.Fatalf("call() error = %v, want the libvirt error passed through", err)
}
if err := p.call(context.Background(), testHost, noop); err != nil {
if err := p.call(context.Background(), discard, testHost, noop); err != nil {
t.Fatalf("call() error = %v", err)
}
if d.dialCount() != 1 {
@@ -146,7 +151,7 @@ func TestHostPool_call_dialErrorPropagates(t *testing.T) {
t.Parallel()
d := &fakeDialer{err: errors.New("ssh: handshake failed")}
p := newHostPool(d.dial)
err := p.call(context.Background(), testHost, noop)
err := p.call(context.Background(), discard, testHost, noop)
if err == nil || !strings.Contains(err.Error(), "nas1") || !strings.Contains(err.Error(), "handshake failed") {
t.Errorf("call() error = %v, want the dial error naming the host", err)
}
@@ -162,7 +167,7 @@ func TestHostPool_call_cancelAbandonsBlockedCall(t *testing.T) {
time.Sleep(20 * time.Millisecond)
cancel()
}()
err := p.call(ctx, testHost, func(libvirtAPI) error {
err := p.call(ctx, discard, testHost, func(libvirtAPI) error {
// Simulates an RPC stuck on a hung hypervisor: it only returns once
// the pool closes the connection out from under it.
<-d.clients[0].done
@@ -177,7 +182,7 @@ func TestHostPool_call_cancelAbandonsBlockedCall(t *testing.T) {
case <-time.After(time.Second):
t.Fatal("blocked call was not released by closing the connection")
}
if err := p.call(context.Background(), testHost, noop); err != nil {
if err := p.call(context.Background(), discard, testHost, noop); err != nil {
t.Fatalf("call() after cancel = %v", err)
}
if d.dialCount() != 2 {
@@ -192,7 +197,7 @@ func TestHostPool_call_concurrentCallersShareOneDial(t *testing.T) {
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
if err := p.call(context.Background(), testHost, noop); err != nil {
if err := p.call(context.Background(), discard, testHost, noop); err != nil {
t.Errorf("call() error = %v", err)
}
})
@@ -210,7 +215,7 @@ func TestHostPool_call_hostsAreIndependent(t *testing.T) {
other := testHost
other.Name = "lab2"
for _, h := range []provider.KVMHost{testHost, other, testHost, other} {
if err := p.call(context.Background(), h, noop); err != nil {
if err := p.call(context.Background(), discard, h, noop); err != nil {
t.Fatalf("call(%s) error = %v", h.Name, err)
}
}
@@ -567,3 +572,83 @@ func TestKeepalive_keepsHealthyClientOpen(t *testing.T) {
}
conn.Close()
}
func TestHostPool_call_tracesAtV1(t *testing.T) {
t.Parallel()
d := &fakeDialer{api: readyHost()}
p := newHostPool(d.dial)
log, snapshot := captureLogger(1)
err := p.call(context.Background(), log, testHost, func(api libvirtAPI) error {
_, err := api.StoragePoolLookupByName("default")
return err
})
if err != nil {
t.Fatalf("call() error = %v", err)
}
joined := strings.Join(snapshot(), "\n")
for _, want := range []string{`"msg"="libvirt host connected"`, `"address"="nas1:22"`, `"method"="StoragePoolLookupByName"`, `"pool"="default"`, "libvirt "} {
if !strings.Contains(joined, want) {
t.Errorf("output missing %s:\n%s", want, joined)
}
}
}
func TestHostPool_call_noTracingBelowV1(t *testing.T) {
t.Parallel()
inner := readyHost()
d := &fakeDialer{api: inner}
p := newHostPool(d.dial)
log, snapshot := captureLogger(0)
err := p.call(context.Background(), log, testHost, func(api libvirtAPI) error {
if api != libvirtAPI(inner) {
t.Errorf("fn received %T, want the bare fake when V(1) is off", api)
}
return nil
})
if err != nil {
t.Fatalf("call() error = %v", err)
}
if lines := snapshot(); len(lines) != 0 {
t.Errorf("verbosity 0 logged %d lines:\n%s", len(lines), strings.Join(lines, "\n"))
}
}
func TestHostPool_call_logsDropReason(t *testing.T) {
t.Parallel()
t.Run("transport error", func(t *testing.T) {
t.Parallel()
d := &fakeDialer{}
p := newHostPool(d.dial)
log, snapshot := captureLogger(1)
_ = p.call(context.Background(), log, testHost, func(libvirtAPI) error { return io.ErrUnexpectedEOF })
joined := strings.Join(snapshot(), "\n")
if !strings.Contains(joined, `"msg"="libvirt connection dropped"`) || !strings.Contains(joined, `"reason"="transport error"`) {
t.Errorf("output missing the drop line:\n%s", joined)
}
})
t.Run("context cancelled", func(t *testing.T) {
t.Parallel()
d := &fakeDialer{}
p := newHostPool(d.dial)
log, snapshot := captureLogger(1)
ctx, cancel := context.WithCancel(context.Background())
cancel()
released := make(chan struct{})
err := p.call(ctx, log, testHost, func(libvirtAPI) error {
<-d.clients[0].done
close(released)
return io.EOF
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("call() error = %v, want context.Canceled", err)
}
<-released
joined := strings.Join(snapshot(), "\n")
if !strings.Contains(joined, `"reason"="context cancelled"`) {
t.Errorf("output missing the cancel drop line:\n%s", joined)
}
if strings.Contains(joined, `"reason"="transport error"`) {
t.Errorf("cancel was also reported as a transport error:\n%s", joined)
}
})
}