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:
+24
-1
@@ -186,6 +186,27 @@ replaces the VM).
|
||||
- `virsh domifaddr proxy-abc… --source arp` / `--source agent` shows what
|
||||
the operator sees when it looks for the IP.
|
||||
|
||||
## Logging
|
||||
|
||||
The provider logs through controller-runtime's logger, named `kvm`, with
|
||||
`provider`, `host` and `name` (the domain) on every line.
|
||||
|
||||
- `--zap-log-level=debug` (logr V(1)): one line per provider operation
|
||||
(`domain defined`, `KVM domain ensured running`, ...) **and one
|
||||
`libvirt call` line per libvirt RPC**, named `kvm.libvirt`, with
|
||||
`method`, the identifiers it was given (`domain`, `pool`, `volume`),
|
||||
curated results (`state`, `path`, `interfaces`), `duration`, and on
|
||||
failure `error` plus `libvirtCode`, `libvirtCodeName` and
|
||||
`libvirtMessage`. Connection events (`libvirt host connected`, dial
|
||||
failures, `libvirt connection dropped` with a reason) log at the same
|
||||
level. A Create is about ten RPCs; a Get is four.
|
||||
- `--zap-log-level=2`: the same lines also carry the domain and volume
|
||||
`xml`, the `metadata` element, the interface `addresses` and the
|
||||
domain `names` from a listing.
|
||||
- Cloud-init is never logged at any level: the seed ISO upload reports
|
||||
only `length` and `duration`. There is no raw wire tier like the GCP
|
||||
provider's V(5); go-libvirt exposes no hook for it.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **No quota or capacity detection.** libvirt reports an out-of-space
|
||||
@@ -211,4 +232,6 @@ replaces the VM).
|
||||
|
||||
Unit tests run over an in-memory fake of the libvirt API. The optional
|
||||
integration test against a real host is described in
|
||||
[testing.md](testing.md#the-kvm-integration-test-real-hypervisor).
|
||||
[testing.md](testing.md#the-kvm-integration-test-real-hypervisor); its
|
||||
`-v` output includes the `libvirt call` trace above, at the verbosity
|
||||
`KVM_TEST_LOG_LEVEL` selects (default 1).
|
||||
|
||||
@@ -462,3 +462,18 @@ lookup already selected the element by URI. The fake now strips the
|
||||
declaration when storing metadata, so the provider's Get and ListByTag
|
||||
tests parse the shape libvirt really returns, and a new
|
||||
`TestParseInstanceMetadata_libvirtBareForm` pins it.
|
||||
|
||||
### Run 4: guest hangs at GRUB's "Booting" line
|
||||
|
||||
Create and start passed; Get polled `Provisioning` with no IP while the
|
||||
VM sat at 100 % CPU after GRUB printed "Booting `Debian GNU/Linux`" and
|
||||
before the kernel wrote anything to the serial console. The domain XML
|
||||
matches a working VM on the same host (q35, BIOS, host-passthrough,
|
||||
virtio disk, SATA cdrom, br0), so the difference is the disk content:
|
||||
the cached image was downloaded without a checksum and never verified.
|
||||
Diagnosis was handed to the user (sha512 against Debian's published
|
||||
sums, `qemu-img check`, and a `virt-install --import` of the same image
|
||||
outside the operator). Because the provider's own logs were invisible in
|
||||
the test output, this run also produced a side plan:
|
||||
[2026-09-10-0805-kvm-libvirt-tracing](2026-09-10-0805-kvm-libvirt-tracing.md),
|
||||
which traces every libvirt RPC at V(1) and routes it into `-v` output.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Execution: Trace every libvirt call in the KVM provider
|
||||
|
||||
Plan: [2026-09-10-0805-kvm-libvirt-tracing.md](../plans/2026-09-10-0805-kvm-libvirt-tracing.md)
|
||||
|
||||
- [x] Step 0 — Save plan
|
||||
- [x] Step 1 — `errors.go`: shared error fields
|
||||
- [x] Step 2 — `trace.go`: the `tracingAPI` decorator
|
||||
- [x] Step 3 — `conn.go`: pool logging and wrapping
|
||||
- [x] Step 4 — Call sites
|
||||
- [x] Step 5 — Integration test output
|
||||
- [x] Step 6 — Unit tests
|
||||
- [ ] Step 7 — Docs, execution log, CHANGELOG (docs and this log done; CHANGELOG waits for the live run)
|
||||
|
||||
## Steps 1–6 — Tracer, pool logging, tests, integration wiring
|
||||
|
||||
Done as one commit on `feat/kvm-provider`; the plan's assumption that
|
||||
this belongs on the KVM branch rather than its own feature branch was
|
||||
accepted at plan approval.
|
||||
|
||||
The code follows the plan closely. Things that came out differently
|
||||
or are worth knowing:
|
||||
|
||||
- **Logger names join with `/` in funcr, `.` in zap.** The trace lines
|
||||
are `kvm/libvirt` in the unit-test capture and in `testr` output, and
|
||||
`kvm.libvirt` under the operator's zap logger. The docs use the zap
|
||||
spelling; the unit test asserts the funcr one. First run of the tier
|
||||
test failed on exactly this.
|
||||
- **Cancel is not reported as a transport error.** The original `call`
|
||||
dropped the connection twice on cancellation (once from the abort
|
||||
closure, once from the "not a libvirt error" branch, harmlessly, since
|
||||
drop is idempotent). With a log line on each drop that would have
|
||||
printed both `context cancelled` and `transport error` for one event,
|
||||
so the transport branch now skips `ctx.Err()`. Covered by
|
||||
`TestHostPool_call_logsDropReason`.
|
||||
- **`ConnectListAllDomains` in the fake includes every defined domain**,
|
||||
so the trace test's fixture (one running managed domain plus one
|
||||
stopped domain for `DomainCreate` to start) lists two; the V(2)
|
||||
assertion checks for the `names` key rather than an exact list.
|
||||
- **`libvirtErrorFields` also feeds `logAPIError`**, which therefore
|
||||
gains `libvirtCodeName` (e.g. `ErrNoDomain`) next to the numeric code.
|
||||
- The seam-coverage guard (`reflect` count of `libvirtAPI` methods vs
|
||||
the test table) is the only thing standing between a future 18th RPC
|
||||
and a silently untraced call; keep it.
|
||||
|
||||
Verification run before committing:
|
||||
|
||||
```bash
|
||||
go build ./... && go vet ./... && go vet -tags=integration ./internal/provider/kvm/
|
||||
go test -short ./...
|
||||
go test -race -count=1 -cover ./internal/provider/kvm/ # 90.2 %, up from 88.2
|
||||
go test -tags=integration -count=1 -run TestKVM ./internal/provider/kvm/ # self-skips without env
|
||||
```
|
||||
|
||||
Worth noting: the live run (`make test-integration-kvm` against beelink)
|
||||
had not happened when this was written; it is what Step 7 of the KVM
|
||||
provider plan is waiting on, and its output is the acceptance test for
|
||||
this feature. Expected shape is in the plan's Verification section.
|
||||
+10
-3
@@ -56,7 +56,8 @@ manual runs too.
|
||||
| `internal/provider/gcp` | `insert_test.go`, `errors_test.go`, `gcp_test.go` | field-by-field `buildInsertRequest`; HTTP-code classification table; zone-qualified IDs, 409-is-success, 9-row state mapping, ListByTag filter + `ReturnPartialSuccess` | pure builder needs **no fake**; the rest uses the flattened `instancesAPI` seam |
|
||||
| `internal/provider/kvm` | `domainxml_test.go`, `volume_test.go`, `seed_test.go`, `id_test.go`, `metadata_test.go` | pure builders: domain/volume XML unmarshalled back and checked field by field, seed ISO reopened with the same library, providerID round-trip, metadata in both namespace spellings | pure |
|
||||
| | `errors_test.go`, `conn_test.go` | libvirt-code classification table; connection pool (reuse, redial, drop on transport error, cancel releases a blocked RPC, one dial per host under concurrency); SSH dial, host-key policy, keepalive | fake dialer; a **real in-process `x/crypto/ssh` server** forwarding a unix socket |
|
||||
| | `image_test.go`, `kvm_test.go` | image cache (fresh fetch, ready, marker missing, checksum change/mismatch, 4xx/5xx, concurrent callers); Create resume after every partial step, leftover truncated seed, state/IP mapping, every Delete already-gone case, ListByTag across hosts / one down / unreadable domain | `httptest` image server + in-memory `fakeAPI` behind the `libvirtAPI` seam |
|
||||
| | `image_test.go`, `kvm_test.go` | image cache (fresh fetch, ready, marker missing, checksum change/mismatch, 4xx/5xx, concurrent callers); Create resume after every partial step, leftover truncated seed, state/IP mapping, every Delete already-gone case, ListByTag across hosts / one down / unreadable domain; logging tiers (V0 silent, V1 one line per RPC, V2 XML) with cloud-init never logged | `httptest` image server + in-memory `fakeAPI` behind the `libvirtAPI` seam |
|
||||
| | `trace_test.go`, `conn_test.go`, `errors_test.go` | every `libvirtAPI` method traced exactly once (guarded by reflection on the interface), results and errors pass through, libvirt error fields, detail keys only at V2, upload content never logged; pool connect/drop lines, no wrapping below V1 | `fakeAPI` + `funcr` capture |
|
||||
| | `kvm_integration_test.go` (`-tags=integration`) | one real VM: image fetch, Create, idempotent re-Create, Running with IP, ListByTag, Delete, pool state afterwards | a **real hypervisor** — see below |
|
||||
| `internal/health` | `probe_test.go` | probes through a **real CONNECT-capable proxy stub** to a real TLS server: tunnel success, refused CONNECT, wrong status, dead proxy, plain-http forward | `httptest` + hijacked bidirectional tunnel |
|
||||
| | `engine_test.go` | thresholds, first-verdict, latency suppression matrix, dropped-event retry, stale-probe UID guard, tick scheduling/seeding/pruning, `Start` end-to-end | fake `client.Reader` + injected `probeFn` |
|
||||
@@ -116,8 +117,14 @@ make test-integration-kvm
|
||||
Optional: `KVM_TEST_KNOWN_HOSTS` (without it host keys are **not**
|
||||
verified — the log says so), `KVM_TEST_IMAGE_SHA256`, `KVM_TEST_POOL`
|
||||
(default `default`), `KVM_TEST_SOCKET`, `KVM_TEST_TIMEOUT` (default
|
||||
`20m`, must cover the first-time image download), and `KVM_TEST_KEEP`
|
||||
to leave the VM behind for `virsh console` when something goes wrong.
|
||||
`20m`, must cover the first-time image download), `KVM_TEST_KEEP`
|
||||
to leave the VM behind for `virsh console` when something goes wrong,
|
||||
and `KVM_TEST_LOG_LEVEL` (default `1`) for how much of the provider's
|
||||
logging the `-v` output shows: at `1` every libvirt RPC is one
|
||||
`libvirt call` line with method, identifiers, duration and error code,
|
||||
interleaved with the test's own `t.Log` lines; `2` adds the domain and
|
||||
volume XML, metadata and addresses; `0` leaves only `t.Log`. See the
|
||||
Logging section of [kvm-provider.md](kvm-provider.md#logging).
|
||||
|
||||
What one run asserts, in order: `New` dials the host; `Create` returns a
|
||||
`hosts/<host>/domains/<name>` ID (downloading the image on a cold host —
|
||||
|
||||
@@ -2,6 +2,7 @@ package kvm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -10,6 +11,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"
|
||||
|
||||
@@ -95,7 +97,7 @@ func (p *hostPool) entry(name string) *hostEntry {
|
||||
return e
|
||||
}
|
||||
|
||||
func (p *hostPool) get(ctx context.Context, host provider.KVMHost) (*hostClient, error) {
|
||||
func (p *hostPool) get(ctx context.Context, log logr.Logger, host provider.KVMHost) (*hostClient, error) {
|
||||
e := p.entry(host.Name)
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
@@ -103,13 +105,17 @@ func (p *hostPool) get(ctx context.Context, host provider.KVMHost) (*hostClient,
|
||||
if e.client.alive() {
|
||||
return e.client, nil
|
||||
}
|
||||
log.V(1).Info("libvirt connection lost, redialing", "address", host.Address)
|
||||
e.client.close()
|
||||
e.client = nil
|
||||
}
|
||||
start := time.Now()
|
||||
c, err := p.dial(ctx, host)
|
||||
if err != nil {
|
||||
log.V(1).Info("libvirt host dial failed", "address", host.Address, "duration", time.Since(start).Round(time.Millisecond), "error", err.Error())
|
||||
return nil, fmt.Errorf("connecting to host %s (%s): %w", host.Name, host.Address, err)
|
||||
}
|
||||
log.V(1).Info("libvirt host connected", "address", host.Address, "duration", time.Since(start).Round(time.Millisecond))
|
||||
e.client = c
|
||||
return c, nil
|
||||
}
|
||||
@@ -131,14 +137,24 @@ func (p *hostPool) drop(host string, c *hostClient) {
|
||||
// is closed — which unblocks fn with an error — and ctx.Err() is returned.
|
||||
// A transport error (anything that is not a libvirt.Error) also drops the
|
||||
// cached connection so the next call redials instead of reusing a dead
|
||||
// link.
|
||||
func (p *hostPool) call(ctx context.Context, host provider.KVMHost, fn func(api libvirtAPI) error) error {
|
||||
c, err := p.get(ctx, host)
|
||||
// link. When log has V(1) enabled, every RPC fn makes is traced through it
|
||||
// (see tracingAPI), as are connection events.
|
||||
func (p *hostPool) call(ctx context.Context, log logr.Logger, host provider.KVMHost, fn func(api libvirtAPI) error) error {
|
||||
log = log.WithName("libvirt")
|
||||
c, err := p.get(ctx, log, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = awaitOrCancel(ctx, func() error { return fn(c.api) }, func() { p.drop(host.Name, c) })
|
||||
if err != nil && !isLibvirtError(err) {
|
||||
api := c.api
|
||||
if log.V(1).Enabled() {
|
||||
api = &tracingAPI{inner: c.api, log: log}
|
||||
}
|
||||
err = awaitOrCancel(ctx, func() error { return fn(api) }, func() {
|
||||
log.V(1).Info("libvirt connection dropped", "reason", "context cancelled", "error", ctx.Err().Error())
|
||||
p.drop(host.Name, c)
|
||||
})
|
||||
if err != nil && !isLibvirtError(err) && !errors.Is(err, ctx.Err()) {
|
||||
log.V(1).Info("libvirt connection dropped", "reason", "transport error", "error", err.Error())
|
||||
p.drop(host.Name, c)
|
||||
}
|
||||
return err
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,10 +63,21 @@ func logAPIError(log logr.Logger, op string, err error) {
|
||||
if !log.V(1).Enabled() {
|
||||
return
|
||||
}
|
||||
kv := []any{"op", op, "error", err.Error()}
|
||||
var lerr libvirt.Error
|
||||
if errors.As(err, &lerr) {
|
||||
kv = append(kv, "libvirtCode", lerr.Code, "libvirtMessage", lerr.Message)
|
||||
}
|
||||
kv := append([]any{"op", op, "error", err.Error()}, libvirtErrorFields(err)...)
|
||||
log.V(1).Info("libvirt call failed", kv...)
|
||||
}
|
||||
|
||||
// libvirtErrorFields renders a daemon-reported error as log key/values:
|
||||
// the numeric code, its constant name (ErrNoDomain, ...) and the daemon's
|
||||
// message. Nil for transport and other non-libvirt errors.
|
||||
func libvirtErrorFields(err error) []any {
|
||||
var lerr libvirt.Error
|
||||
if !errors.As(err, &lerr) {
|
||||
return nil
|
||||
}
|
||||
return []any{
|
||||
"libvirtCode", lerr.Code,
|
||||
"libvirtCodeName", libvirt.ErrorNumber(lerr.Code).String(),
|
||||
"libvirtMessage", lerr.Message,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
libvirt "github.com/digitalocean/go-libvirt"
|
||||
@@ -67,3 +68,26 @@ func TestHasCode(t *testing.T) {
|
||||
t.Error("io.EOF classified as a libvirt error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibvirtErrorFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want []any
|
||||
}{
|
||||
{"libvirt error", lvErr(libvirt.ErrNoDomain), []any{"libvirtCode", uint32(42), "libvirtCodeName", "ErrNoDomain", "libvirtMessage", "ErrNoDomain"}},
|
||||
{"wrapped libvirt error", fmt.Errorf("looking up: %w", lvErr(libvirt.ErrNoDomain)), []any{"libvirtCode", uint32(42), "libvirtCodeName", "ErrNoDomain", "libvirtMessage", "ErrNoDomain"}},
|
||||
{"transport error", io.EOF, nil},
|
||||
{"cancelled", context.Canceled, nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := libvirtErrorFields(tc.err)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("libvirtErrorFields() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ func (p *Provider) Create(ctx context.Context, req provider.CreateRequest) (stri
|
||||
"vcpus", mt.VCPUs, "memoryMiB", mt.MemoryMiB, "diskSizeGb", p.cfg.DiskSizeGB,
|
||||
"image", src.volumeName(), "cloudInitBytes", len(req.CloudInit))
|
||||
|
||||
err = p.hosts.call(ctx, host, func(api libvirtAPI) error {
|
||||
err = p.hosts.call(ctx, log, host, func(api libvirtAPI) error {
|
||||
dom, err := api.DomainLookupByName(req.Name)
|
||||
if err == nil {
|
||||
log.V(1).Info("domain already defined")
|
||||
@@ -319,7 +319,7 @@ func (p *Provider) Get(ctx context.Context, providerID string) (*provider.Instan
|
||||
}
|
||||
log := p.logger(ctx).WithValues("host", host.Name, "name", name)
|
||||
var inst *provider.Instance
|
||||
err = p.hosts.call(ctx, host, func(api libvirtAPI) error {
|
||||
err = p.hosts.call(ctx, log, host, func(api libvirtAPI) error {
|
||||
dom, err := api.DomainLookupByName(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("looking up domain: %w", err)
|
||||
@@ -455,7 +455,7 @@ func (p *Provider) Delete(ctx context.Context, providerID string) error {
|
||||
return p.wrapErr("delete", providerID, err)
|
||||
}
|
||||
log := p.logger(ctx).WithValues("host", host.Name, "name", name)
|
||||
err = p.hosts.call(ctx, host, func(api libvirtAPI) error {
|
||||
err = p.hosts.call(ctx, log, host, func(api libvirtAPI) error {
|
||||
if err := p.removeDomain(log, api, name); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -527,7 +527,7 @@ func (p *Provider) ListByTag(ctx context.Context) ([]provider.Instance, error) {
|
||||
var out []provider.Instance
|
||||
for _, host := range p.cfg.Hosts {
|
||||
hlog := log.WithValues("host", host.Name)
|
||||
err := p.hosts.call(ctx, host, func(api libvirtAPI) error {
|
||||
err := p.hosts.call(ctx, hlog, host, func(api libvirtAPI) error {
|
||||
doms, _, err := api.ConnectListAllDomains(1, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing domains: %w", err)
|
||||
|
||||
@@ -25,6 +25,14 @@ package kvm
|
||||
// KVM_TEST_SOCKET libvirt socket path on the host
|
||||
// KVM_TEST_TIMEOUT overall budget incl. the image download (default 20m)
|
||||
// KVM_TEST_KEEP set to anything to leave the VM behind for inspection
|
||||
// KVM_TEST_LOG_LEVEL provider log verbosity shown in -v output (default 1:
|
||||
// one line per libvirt call; 2 adds XML, metadata and
|
||||
// addresses; 0 leaves only the t.Log lines)
|
||||
//
|
||||
// Provider logs go through a testr logger, so they interleave with t.Log
|
||||
// output. A run that hits its timeout may log one more line from the RPC
|
||||
// the pool abandoned, after the test has ended; that is a symptom of the
|
||||
// timeout, not a bug in the tracer.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -34,11 +42,15 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
libvirt "github.com/digitalocean/go-libvirt"
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/go-logr/logr/testr"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||
)
|
||||
@@ -53,6 +65,7 @@ type integrationEnv struct {
|
||||
cfg provider.ProviderConfig
|
||||
timeout time.Duration
|
||||
keep bool
|
||||
logLevel int
|
||||
}
|
||||
|
||||
func loadIntegrationEnv(t *testing.T) integrationEnv {
|
||||
@@ -97,7 +110,26 @@ func loadIntegrationEnv(t *testing.T) integrationEnv {
|
||||
}
|
||||
timeout = d
|
||||
}
|
||||
return integrationEnv{cfg: pc, timeout: timeout, keep: os.Getenv("KVM_TEST_KEEP") != ""}
|
||||
logLevel := 1
|
||||
if v := os.Getenv("KVM_TEST_LOG_LEVEL"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
t.Fatalf("KVM_TEST_LOG_LEVEL: %v", err)
|
||||
}
|
||||
logLevel = n
|
||||
}
|
||||
return integrationEnv{cfg: pc, timeout: timeout, keep: os.Getenv("KVM_TEST_KEEP") != "", logLevel: logLevel}
|
||||
}
|
||||
|
||||
// integrationLogger routes provider logs into the test output at the
|
||||
// requested verbosity, and installs it as controller-runtime's root logger
|
||||
// so nothing falls back to the unfulfilled delegating sink (which would
|
||||
// print its "SetLogger was never called" warning and swallow the lines).
|
||||
func integrationLogger(t *testing.T, verbosity int) logr.Logger {
|
||||
t.Helper()
|
||||
log := testr.NewWithOptions(t, testr.Options{LogTimestamp: true, Verbosity: verbosity})
|
||||
logf.SetLogger(log)
|
||||
return log
|
||||
}
|
||||
|
||||
func randomUID(t *testing.T) string {
|
||||
@@ -114,7 +146,8 @@ func randomUID(t *testing.T) string {
|
||||
// stage depends on the state the previous one left on the hypervisor.
|
||||
func TestKVM_lifecycle(t *testing.T) {
|
||||
env := loadIntegrationEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), env.timeout)
|
||||
log := integrationLogger(t, env.logLevel)
|
||||
ctx, cancel := context.WithTimeout(logr.NewContext(context.Background(), log), env.timeout)
|
||||
defer cancel()
|
||||
|
||||
prov, err := New(ctx, env.cfg)
|
||||
@@ -143,7 +176,7 @@ func TestKVM_lifecycle(t *testing.T) {
|
||||
t.Logf("KVM_TEST_KEEP set: leaving domain %s and its volumes on %s", req.Name, host.Address)
|
||||
return
|
||||
}
|
||||
cctx, ccancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
cctx, ccancel := context.WithTimeout(logr.NewContext(context.Background(), log), 2*time.Minute)
|
||||
defer ccancel()
|
||||
if err := p.Delete(cctx, formatProviderID(host.Name, req.Name)); err != nil {
|
||||
t.Errorf("cleanup Delete: %v", err)
|
||||
@@ -217,7 +250,7 @@ func TestKVM_lifecycle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = p.hosts.call(ctx, host, func(api libvirtAPI) error {
|
||||
err = p.hosts.call(ctx, p.logger(ctx).WithValues("host", host.Name), host, func(api libvirtAPI) error {
|
||||
pool, err := api.StoragePoolLookupByName(host.Pool)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -885,3 +885,96 @@ func TestNew_badKeyPathFailsFast(t *testing.T) {
|
||||
t.Fatal("New succeeded with a missing key file")
|
||||
}
|
||||
}
|
||||
|
||||
// runAllOps drives one full lifecycle over a ready fake host so logging
|
||||
// tests see every provider method and every libvirt call.
|
||||
func runAllOps(t *testing.T, ctx context.Context, req provider.CreateRequest) {
|
||||
t.Helper()
|
||||
p := newTestProvider(hostFakes{"nas1": readyHost()}, "nas1")
|
||||
id, err := p.Create(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := p.Get(ctx, id); err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if _, err := p.ListByTag(ctx); err != nil {
|
||||
t.Fatalf("ListByTag: %v", err)
|
||||
}
|
||||
if err := p.Delete(ctx, id); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogging_verbosityTiers(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
verbosity int
|
||||
wantLines []string
|
||||
absentLines []string
|
||||
}{
|
||||
{
|
||||
name: "v0 stays silent",
|
||||
verbosity: 0,
|
||||
absentLines: []string{"libvirt call", "KVM domain ensured running"},
|
||||
},
|
||||
{
|
||||
name: "v1 logs one line per libvirt call plus the operation outcomes",
|
||||
verbosity: 1,
|
||||
wantLines: []string{
|
||||
`kvm/libvirt "level"=1 "msg"="libvirt host connected"`,
|
||||
`"msg"="libvirt call"`,
|
||||
`"method"="DomainLookupByName"`,
|
||||
`"libvirtCodeName"="ErrNoDomain"`,
|
||||
`"method"="StorageVolUpload"`,
|
||||
`"method"="DomainDefineXML"`,
|
||||
`"method"="DomainCreate"`,
|
||||
`"method"="DomainInterfaceAddresses"`,
|
||||
`"method"="ConnectListAllDomains"`,
|
||||
`"method"="DomainUndefineFlags"`,
|
||||
`"method"="StorageVolDelete"`,
|
||||
`"provider"="kvm-home"`,
|
||||
`"host"="nas1"`,
|
||||
`"name"="` + testName + `"`,
|
||||
`"msg"="KVM domain ensured running"`,
|
||||
},
|
||||
absentLines: []string{`"xml"=`, `"metadata"=`},
|
||||
},
|
||||
{
|
||||
name: "v2 adds xml, metadata and addresses on the same lines",
|
||||
verbosity: 2,
|
||||
wantLines: []string{`"xml"="<domain`, `"xml"="<volume`, `"metadata"="<instance`, `"names"=[`},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, snapshot := captureContext(tc.verbosity)
|
||||
req := testRequest()
|
||||
const sentinel = "SENTINEL-cloud-init-must-never-be-logged"
|
||||
req.CloudInit = "#cloud-config\n" + sentinel + "\n"
|
||||
|
||||
runAllOps(t, ctx, req)
|
||||
|
||||
lines := snapshot()
|
||||
joined := strings.Join(lines, "\n")
|
||||
if tc.verbosity == 0 && len(lines) != 0 {
|
||||
t.Errorf("verbosity 0 logged %d lines:\n%s", len(lines), joined)
|
||||
}
|
||||
for _, want := range tc.wantLines {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("output missing %s:\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
for _, absent := range tc.absentLines {
|
||||
if strings.Contains(joined, absent) {
|
||||
t.Errorf("output unexpectedly contains %s:\n%s", absent, joined)
|
||||
}
|
||||
}
|
||||
if strings.Contains(joined, sentinel) {
|
||||
t.Errorf("cloud-init content leaked into logs:\n%s", joined)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package kvm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
libvirt "github.com/digitalocean/go-libvirt"
|
||||
"github.com/go-logr/logr"
|
||||
)
|
||||
|
||||
// tracingAPI wraps a libvirtAPI and logs one V(1) line per call after it
|
||||
// returns: method, the identifiers it was given, curated results, duration,
|
||||
// and the libvirt error fields on failure. At V(2) the same line also
|
||||
// carries XML bodies, metadata and address lists. Upload and download
|
||||
// bodies are never logged: the seed ISO carries cloud-init.
|
||||
type tracingAPI struct {
|
||||
inner libvirtAPI
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
type tracedCall struct {
|
||||
t *tracingAPI
|
||||
method string
|
||||
start time.Time
|
||||
kv []any
|
||||
}
|
||||
|
||||
func (t *tracingAPI) begin(method string, kv ...any) *tracedCall {
|
||||
return &tracedCall{t: t, method: method, start: time.Now(), kv: kv}
|
||||
}
|
||||
|
||||
// detail adds key/values that are only worth the bytes at V(2).
|
||||
func (c *tracedCall) detail(kv ...any) *tracedCall {
|
||||
if c.t.log.V(2).Enabled() {
|
||||
c.kv = append(c.kv, kv...)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *tracedCall) done(err error, kv ...any) {
|
||||
fields := make([]any, 0, 2+len(c.kv)+len(kv)+10)
|
||||
fields = append(fields, "method", c.method)
|
||||
fields = append(fields, c.kv...)
|
||||
fields = append(fields, kv...)
|
||||
fields = append(fields, "duration", time.Since(c.start).Round(time.Microsecond))
|
||||
if err != nil {
|
||||
fields = append(fields, "error", err.Error())
|
||||
fields = append(fields, libvirtErrorFields(err)...)
|
||||
}
|
||||
c.t.log.V(1).Info("libvirt call", fields...)
|
||||
}
|
||||
|
||||
// flagKV renders a flags value only when something is set; go-libvirt's
|
||||
// generated stringers do not know bitmask combinations.
|
||||
func flagKV(v int64) []any {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return []any{"flags", v}
|
||||
}
|
||||
|
||||
func optString(s libvirt.OptString) string {
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
return s[0]
|
||||
}
|
||||
|
||||
func (t *tracingAPI) DomainLookupByName(name string) (libvirt.Domain, error) {
|
||||
c := t.begin("DomainLookupByName", "domain", name)
|
||||
d, err := t.inner.DomainLookupByName(name)
|
||||
c.done(err)
|
||||
return d, err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) DomainDefineXML(xml string) (libvirt.Domain, error) {
|
||||
c := t.begin("DomainDefineXML", "xmlBytes", len(xml)).detail("xml", xml)
|
||||
d, err := t.inner.DomainDefineXML(xml)
|
||||
c.done(err, "domain", d.Name)
|
||||
return d, err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) DomainCreate(d libvirt.Domain) error {
|
||||
c := t.begin("DomainCreate", "domain", d.Name)
|
||||
err := t.inner.DomainCreate(d)
|
||||
c.done(err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) DomainDestroy(d libvirt.Domain) error {
|
||||
c := t.begin("DomainDestroy", "domain", d.Name)
|
||||
err := t.inner.DomainDestroy(d)
|
||||
c.done(err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) DomainUndefineFlags(d libvirt.Domain, flags libvirt.DomainUndefineFlagsValues) error {
|
||||
c := t.begin("DomainUndefineFlags", append([]any{"domain", d.Name}, flagKV(int64(flags))...)...)
|
||||
err := t.inner.DomainUndefineFlags(d, flags)
|
||||
c.done(err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) DomainGetState(d libvirt.Domain, flags uint32) (int32, int32, error) {
|
||||
c := t.begin("DomainGetState", "domain", d.Name)
|
||||
state, reason, err := t.inner.DomainGetState(d, flags)
|
||||
c.done(err, "state", libvirt.DomainState(state), "reason", reason)
|
||||
return state, reason, err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) DomainGetMetadata(d libvirt.Domain, typ int32, uri libvirt.OptString, flags libvirt.DomainModificationImpact) (string, error) {
|
||||
c := t.begin("DomainGetMetadata", "domain", d.Name, "type", typ, "uri", optString(uri))
|
||||
s, err := t.inner.DomainGetMetadata(d, typ, uri, flags)
|
||||
c.detail("metadata", s).done(err, "metadataBytes", len(s))
|
||||
return s, err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) DomainInterfaceAddresses(d libvirt.Domain, source uint32, flags uint32) ([]libvirt.DomainInterface, error) {
|
||||
c := t.begin("DomainInterfaceAddresses", "domain", d.Name, "source", libvirt.DomainInterfaceAddressesSource(source))
|
||||
ifaces, err := t.inner.DomainInterfaceAddresses(d, source, flags)
|
||||
c.detail("addresses", formatAddresses(ifaces)).done(err, "interfaces", len(ifaces))
|
||||
return ifaces, err
|
||||
}
|
||||
|
||||
func formatAddresses(ifaces []libvirt.DomainInterface) []string {
|
||||
var out []string
|
||||
for _, iface := range ifaces {
|
||||
for _, a := range iface.Addrs {
|
||||
out = append(out, fmt.Sprintf("%s=%s/%d", iface.Name, a.Addr, a.Prefix))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *tracingAPI) ConnectListAllDomains(needResults int32, flags libvirt.ConnectListAllDomainsFlags) ([]libvirt.Domain, uint32, error) {
|
||||
c := t.begin("ConnectListAllDomains", flagKV(int64(flags))...)
|
||||
doms, n, err := t.inner.ConnectListAllDomains(needResults, flags)
|
||||
names := make([]string, 0, len(doms))
|
||||
for _, d := range doms {
|
||||
names = append(names, d.Name)
|
||||
}
|
||||
c.detail("names", names).done(err, "count", len(doms))
|
||||
return doms, n, err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) StoragePoolLookupByName(name string) (libvirt.StoragePool, error) {
|
||||
c := t.begin("StoragePoolLookupByName", "pool", name)
|
||||
pool, err := t.inner.StoragePoolLookupByName(name)
|
||||
c.done(err)
|
||||
return pool, err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) StoragePoolRefresh(pool libvirt.StoragePool, flags uint32) error {
|
||||
c := t.begin("StoragePoolRefresh", "pool", pool.Name)
|
||||
err := t.inner.StoragePoolRefresh(pool, flags)
|
||||
c.done(err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) StorageVolLookupByName(pool libvirt.StoragePool, name string) (libvirt.StorageVol, error) {
|
||||
c := t.begin("StorageVolLookupByName", "pool", pool.Name, "volume", name)
|
||||
vol, err := t.inner.StorageVolLookupByName(pool, name)
|
||||
c.done(err)
|
||||
return vol, err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) StorageVolGetPath(vol libvirt.StorageVol) (string, error) {
|
||||
c := t.begin("StorageVolGetPath", "pool", vol.Pool, "volume", vol.Name)
|
||||
path, err := t.inner.StorageVolGetPath(vol)
|
||||
c.done(err, "path", path)
|
||||
return path, err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) StorageVolCreateXML(pool libvirt.StoragePool, xml string, flags libvirt.StorageVolCreateFlags) (libvirt.StorageVol, error) {
|
||||
c := t.begin("StorageVolCreateXML", append([]any{"pool", pool.Name, "xmlBytes", len(xml)}, flagKV(int64(flags))...)...).detail("xml", xml)
|
||||
vol, err := t.inner.StorageVolCreateXML(pool, xml, flags)
|
||||
c.done(err, "volume", vol.Name)
|
||||
return vol, err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) StorageVolUpload(vol libvirt.StorageVol, r io.Reader, offset, length uint64, flags libvirt.StorageVolUploadFlags) error {
|
||||
c := t.begin("StorageVolUpload", append([]any{"pool", vol.Pool, "volume", vol.Name, "offset", offset, "length", length}, flagKV(int64(flags))...)...)
|
||||
err := t.inner.StorageVolUpload(vol, r, offset, length, flags)
|
||||
c.done(err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) StorageVolDownload(vol libvirt.StorageVol, w io.Writer, offset, length uint64, flags libvirt.StorageVolDownloadFlags) error {
|
||||
c := t.begin("StorageVolDownload", append([]any{"pool", vol.Pool, "volume", vol.Name, "offset", offset, "length", length}, flagKV(int64(flags))...)...)
|
||||
err := t.inner.StorageVolDownload(vol, w, offset, length, flags)
|
||||
c.done(err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *tracingAPI) StorageVolDelete(vol libvirt.StorageVol, flags libvirt.StorageVolDeleteFlags) error {
|
||||
c := t.begin("StorageVolDelete", append([]any{"pool", vol.Pool, "volume", vol.Name}, flagKV(int64(flags))...)...)
|
||||
err := t.inner.StorageVolDelete(vol, flags)
|
||||
c.done(err)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
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"`, `"method"="` + 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"=`, `"libvirtCode"=42`, `"libvirtCodeName"="ErrNoDomain"`, `"libvirtMessage"="Domain not found`},
|
||||
},
|
||||
{
|
||||
name: "transport error has no libvirt fields",
|
||||
inject: io.EOF,
|
||||
want: []string{`"error"="EOF"`},
|
||||
notWant: []string{`"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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user