The first live run showed the trace working but noisy: testr printed ts/level/msg and the per-run constants on every line, the error text appeared twice, host appeared twice on image-cache lines, and the Go method name was less useful than the libvirt function it maps to. The tracer now logs rpc=virDomainLookupByName (go-libvirt's names are the public API names without the vir prefix), error plus libvirtError (code name) and libvirtCode, and image.go stops re-adding host. The integration test replaces testr with a compact logr sink: time, logger name, the rpc as headline, then fields, with provider/host/name omitted since the first line of the run states them. Docs and execution log updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
301 lines
10 KiB
Go
301 lines
10 KiB
Go
package kvm
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
libvirt "github.com/digitalocean/go-libvirt"
|
|
"github.com/go-logr/logr"
|
|
"github.com/go-logr/logr/funcr"
|
|
)
|
|
|
|
// captureLogger returns a funcr logger capped at verbosity (the test
|
|
// stand-in for --zap-log-level=<verbosity>) and a snapshot function for the
|
|
// lines it recorded. The sink is mutex-guarded: a cancelled call logs from
|
|
// the abandoned goroutine.
|
|
func captureLogger(verbosity int) (logr.Logger, func() []string) {
|
|
var mu sync.Mutex
|
|
var lines []string
|
|
log := funcr.New(func(prefix, args string) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
lines = append(lines, prefix+" "+args)
|
|
}, funcr.Options{Verbosity: verbosity})
|
|
return log, func() []string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return append([]string(nil), lines...)
|
|
}
|
|
}
|
|
|
|
func captureContext(verbosity int) (context.Context, func() []string) {
|
|
log, snapshot := captureLogger(verbosity)
|
|
return logr.NewContext(context.Background(), log), snapshot
|
|
}
|
|
|
|
const stoppedName = "proxy-stopped"
|
|
|
|
// tracedHost is a fake with one managed running domain and its volumes, so
|
|
// every method of the seam has something to act on.
|
|
func tracedHost(t *testing.T) *fakeAPI {
|
|
t.Helper()
|
|
api := readyHost()
|
|
managedDomain(t, api, testName, testUID, true, map[uint32][]libvirt.DomainInterface{
|
|
uint32(libvirt.DomainInterfaceAddressesSrcArp): {{Name: "vnet0", Addrs: []libvirt.DomainIPAddr{ipv4("192.168.1.50")}}},
|
|
})
|
|
api.putDomain(&fakeDomain{name: stoppedName})
|
|
return api
|
|
}
|
|
|
|
func TestTracingAPI_logsEveryMethod(t *testing.T) {
|
|
t.Parallel()
|
|
pool := libvirt.StoragePool{Name: "default"}
|
|
dom := libvirt.Domain{Name: testName}
|
|
overlay := libvirt.StorageVol{Pool: "default", Name: overlayVolumeName(testName)}
|
|
cases := []struct {
|
|
method string
|
|
call func(api libvirtAPI) error
|
|
}{
|
|
{"DomainLookupByName", func(api libvirtAPI) error { _, err := api.DomainLookupByName(testName); return err }},
|
|
{"DomainDefineXML", func(api libvirtAPI) error {
|
|
xml, err := buildDomainXML(testDomainParams())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = api.DomainDefineXML(xml)
|
|
return err
|
|
}},
|
|
{"DomainCreate", func(api libvirtAPI) error { return api.DomainCreate(libvirt.Domain{Name: stoppedName}) }},
|
|
{"DomainDestroy", func(api libvirtAPI) error { return api.DomainDestroy(dom) }},
|
|
{"DomainUndefineFlags", func(api libvirtAPI) error { return api.DomainUndefineFlags(dom, undefineFlags) }},
|
|
{"DomainGetState", func(api libvirtAPI) error { _, _, err := api.DomainGetState(dom, 0); return err }},
|
|
{"DomainGetMetadata", func(api libvirtAPI) error {
|
|
_, err := api.DomainGetMetadata(dom, int32(libvirt.DomainMetadataElement), libvirt.OptString{metadataURI}, 0)
|
|
return err
|
|
}},
|
|
{"DomainInterfaceAddresses", func(api libvirtAPI) error {
|
|
_, err := api.DomainInterfaceAddresses(dom, uint32(libvirt.DomainInterfaceAddressesSrcArp), 0)
|
|
return err
|
|
}},
|
|
{"ConnectListAllDomains", func(api libvirtAPI) error { _, _, err := api.ConnectListAllDomains(1, 0); return err }},
|
|
{"StoragePoolLookupByName", func(api libvirtAPI) error { _, err := api.StoragePoolLookupByName("default"); return err }},
|
|
{"StoragePoolRefresh", func(api libvirtAPI) error { return api.StoragePoolRefresh(pool, 0) }},
|
|
{"StorageVolLookupByName", func(api libvirtAPI) error { _, err := api.StorageVolLookupByName(pool, overlay.Name); return err }},
|
|
{"StorageVolGetPath", func(api libvirtAPI) error { _, err := api.StorageVolGetPath(overlay); return err }},
|
|
{"StorageVolCreateXML", func(api libvirtAPI) error {
|
|
xml, err := buildVolumeXML(rawVolume("new.iso", 3))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = api.StorageVolCreateXML(pool, xml, 0)
|
|
return err
|
|
}},
|
|
{"StorageVolUpload", func(api libvirtAPI) error {
|
|
return api.StorageVolUpload(overlay, strings.NewReader("abc"), 0, 3, 0)
|
|
}},
|
|
{"StorageVolDownload", func(api libvirtAPI) error {
|
|
return api.StorageVolDownload(overlay, io.Discard, 0, 0, 0)
|
|
}},
|
|
{"StorageVolDelete", func(api libvirtAPI) error { return api.StorageVolDelete(overlay, 0) }},
|
|
}
|
|
if n := reflect.TypeOf((*libvirtAPI)(nil)).Elem().NumMethod(); n != len(cases) {
|
|
t.Fatalf("libvirtAPI has %d methods, this test covers %d; trace the new one", n, len(cases))
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.method, func(t *testing.T) {
|
|
t.Parallel()
|
|
log, snapshot := captureLogger(1)
|
|
api := &tracingAPI{inner: tracedHost(t), log: log}
|
|
if err := tc.call(api); err != nil {
|
|
t.Fatalf("%s: %v", tc.method, err)
|
|
}
|
|
lines := snapshot()
|
|
if len(lines) != 1 {
|
|
t.Fatalf("logged %d lines, want exactly 1:\n%s", len(lines), strings.Join(lines, "\n"))
|
|
}
|
|
for _, want := range []string{`"msg"="libvirt call"`, `"rpc"="vir` + tc.method + `"`, `"duration"=`} {
|
|
if !strings.Contains(lines[0], want) {
|
|
t.Errorf("line missing %s:\n%s", want, lines[0])
|
|
}
|
|
}
|
|
if strings.Contains(lines[0], `"error"=`) {
|
|
t.Errorf("successful call logged an error:\n%s", lines[0])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTracingAPI_passesResultsThrough(t *testing.T) {
|
|
t.Parallel()
|
|
inner := tracedHost(t)
|
|
log, _ := captureLogger(1)
|
|
traced := &tracingAPI{inner: inner, log: log}
|
|
dom := libvirt.Domain{Name: testName}
|
|
|
|
wantState, wantReason, err := inner.DomainGetState(dom, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state, reason, err := traced.DomainGetState(dom, 0)
|
|
if err != nil || state != wantState || reason != wantReason {
|
|
t.Errorf("DomainGetState = %d/%d/%v, want %d/%d/nil", state, reason, err, wantState, wantReason)
|
|
}
|
|
|
|
overlay := libvirt.StorageVol{Pool: "default", Name: overlayVolumeName(testName)}
|
|
wantPath, _ := inner.StorageVolGetPath(overlay)
|
|
if path, err := traced.StorageVolGetPath(overlay); err != nil || path != wantPath {
|
|
t.Errorf("StorageVolGetPath = %q/%v, want %q", path, err, wantPath)
|
|
}
|
|
|
|
wantDoms, _, _ := inner.ConnectListAllDomains(1, 0)
|
|
doms, _, err := traced.ConnectListAllDomains(1, 0)
|
|
if err != nil || !reflect.DeepEqual(doms, wantDoms) {
|
|
t.Errorf("ConnectListAllDomains = %v/%v, want %v", doms, err, wantDoms)
|
|
}
|
|
|
|
inner.fail["DomainCreate"] = lvError(libvirt.ErrOperationInvalid, "already running")
|
|
if err := traced.DomainCreate(dom); !hasCode(err, libvirt.ErrOperationInvalid) {
|
|
t.Errorf("DomainCreate error = %v, want the injected libvirt error unchanged", err)
|
|
}
|
|
}
|
|
|
|
func TestTracingAPI_errorFields(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
inject error
|
|
want []string
|
|
notWant []string
|
|
}{
|
|
{
|
|
name: "libvirt error carries code, name and message",
|
|
inject: lvError(libvirt.ErrNoDomain, "Domain not found: no domain with matching name"),
|
|
want: []string{`"error"="Domain not found`, `"libvirtError"="ErrNoDomain"`, `"libvirtCode"=42`},
|
|
},
|
|
{
|
|
name: "transport error has no libvirt fields",
|
|
inject: io.EOF,
|
|
want: []string{`"error"="EOF"`},
|
|
notWant: []string{`"libvirtError"`, `"libvirtCode"`},
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
inner := tracedHost(t)
|
|
inner.fail["DomainLookupByName"] = tc.inject
|
|
log, snapshot := captureLogger(1)
|
|
api := &tracingAPI{inner: inner, log: log}
|
|
if _, err := api.DomainLookupByName(testName); !errors.Is(err, tc.inject) {
|
|
t.Fatalf("error = %v, want the injected one", err)
|
|
}
|
|
joined := strings.Join(snapshot(), "\n")
|
|
for _, want := range tc.want {
|
|
if !strings.Contains(joined, want) {
|
|
t.Errorf("output missing %s:\n%s", want, joined)
|
|
}
|
|
}
|
|
for _, absent := range tc.notWant {
|
|
if strings.Contains(joined, absent) {
|
|
t.Errorf("output unexpectedly contains %s:\n%s", absent, joined)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTracingAPI_detailOnlyAtV2(t *testing.T) {
|
|
t.Parallel()
|
|
dom := libvirt.Domain{Name: testName}
|
|
calls := []struct {
|
|
method string
|
|
call func(api libvirtAPI) error
|
|
always string
|
|
detail string
|
|
}{
|
|
{"DomainDefineXML", func(api libvirtAPI) error {
|
|
xml, err := buildDomainXML(testDomainParams())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = api.DomainDefineXML(xml)
|
|
return err
|
|
}, `"xmlBytes"=`, `"xml"="<domain`},
|
|
{"StorageVolCreateXML", func(api libvirtAPI) error {
|
|
xml, err := buildVolumeXML(rawVolume("new.iso", 3))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = api.StorageVolCreateXML(libvirt.StoragePool{Name: "default"}, xml, 0)
|
|
return err
|
|
}, `"xmlBytes"=`, `"xml"="<volume`},
|
|
{"DomainGetMetadata", func(api libvirtAPI) error {
|
|
_, err := api.DomainGetMetadata(dom, int32(libvirt.DomainMetadataElement), libvirt.OptString{metadataURI}, 0)
|
|
return err
|
|
}, `"metadataBytes"=`, `"metadata"="<instance`},
|
|
{"DomainInterfaceAddresses", func(api libvirtAPI) error {
|
|
_, err := api.DomainInterfaceAddresses(dom, uint32(libvirt.DomainInterfaceAddressesSrcArp), 0)
|
|
return err
|
|
}, `"interfaces"=1`, `"addresses"=["vnet0=192.168.1.50/24"]`},
|
|
{"ConnectListAllDomains", func(api libvirtAPI) error {
|
|
_, _, err := api.ConnectListAllDomains(1, 0)
|
|
return err
|
|
}, `"count"=2`, `"names"=[`},
|
|
}
|
|
for _, verbosity := range []int{1, 2} {
|
|
for _, tc := range calls {
|
|
t.Run(tc.method+"/v"+string(rune('0'+verbosity)), func(t *testing.T) {
|
|
t.Parallel()
|
|
log, snapshot := captureLogger(verbosity)
|
|
api := &tracingAPI{inner: tracedHost(t), log: log}
|
|
if err := tc.call(api); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
joined := strings.Join(snapshot(), "\n")
|
|
if !strings.Contains(joined, tc.always) {
|
|
t.Errorf("v%d output missing %s:\n%s", verbosity, tc.always, joined)
|
|
}
|
|
if has := strings.Contains(joined, tc.detail); has != (verbosity >= 2) {
|
|
t.Errorf("v%d output contains %s = %v, want %v:\n%s", verbosity, tc.detail, has, verbosity >= 2, joined)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTracingAPI_uploadLogsSizeNotContent(t *testing.T) {
|
|
t.Parallel()
|
|
const sentinel = "SENTINEL-seed-content-must-never-be-logged"
|
|
log, snapshot := captureLogger(2)
|
|
api := &tracingAPI{inner: tracedHost(t), log: log}
|
|
vol := libvirt.StorageVol{Pool: "default", Name: seedVolumeName(testName)}
|
|
if err := api.StorageVolUpload(vol, bytes.NewReader([]byte(sentinel)), 0, uint64(len(sentinel)), 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
joined := strings.Join(snapshot(), "\n")
|
|
if !strings.Contains(joined, `"length"=42`) {
|
|
t.Errorf("upload line missing the length:\n%s", joined)
|
|
}
|
|
if strings.Contains(joined, sentinel) {
|
|
t.Errorf("upload content leaked into logs:\n%s", joined)
|
|
}
|
|
}
|
|
|
|
func TestTracingAPI_silentBelowV1(t *testing.T) {
|
|
t.Parallel()
|
|
log, snapshot := captureLogger(0)
|
|
api := &tracingAPI{inner: tracedHost(t), log: log}
|
|
if _, err := api.DomainLookupByName(testName); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if lines := snapshot(); len(lines) != 0 {
|
|
t.Errorf("verbosity 0 logged %d lines:\n%s", len(lines), strings.Join(lines, "\n"))
|
|
}
|
|
}
|