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>
325 lines
11 KiB
Go
325 lines
11 KiB
Go
//go:build integration
|
|
|
|
package kvm
|
|
|
|
// Integration test against a real libvirt/KVM hypervisor. It deviates from
|
|
// the repo's testcontainers convention on purpose: libvirt needs /dev/kvm
|
|
// and a bridge, neither of which a container provides, so this runs
|
|
// against whatever host the KVM_TEST_* variables name. It creates one VM
|
|
// from a stock cloud image, waits for it to report an address, deletes it,
|
|
// and checks the pool afterwards. The base image stays cached on the host.
|
|
//
|
|
// Required:
|
|
//
|
|
// KVM_TEST_HOST SSH endpoint, host:port
|
|
// KVM_TEST_USER SSH user (member of the libvirt group)
|
|
// KVM_TEST_SSH_KEY path to the private key
|
|
// KVM_TEST_BRIDGE host bridge for the VM's NIC
|
|
// KVM_TEST_IMAGE_URL cloud image to fetch (http/https)
|
|
//
|
|
// Optional:
|
|
//
|
|
// KVM_TEST_KNOWN_HOSTS known_hosts file; without it host keys are NOT verified
|
|
// KVM_TEST_IMAGE_SHA256 expected digest of the image
|
|
// KVM_TEST_POOL storage pool (default "default")
|
|
// 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"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
integrationHostName = "kvm-test"
|
|
integrationImageKey = "test-image"
|
|
integrationPoll = 5 * time.Second
|
|
)
|
|
|
|
type integrationEnv struct {
|
|
cfg provider.ProviderConfig
|
|
timeout time.Duration
|
|
keep bool
|
|
logLevel int
|
|
}
|
|
|
|
func loadIntegrationEnv(t *testing.T) integrationEnv {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("integration test skipped under -short")
|
|
}
|
|
required := map[string]string{}
|
|
for _, key := range []string{"KVM_TEST_HOST", "KVM_TEST_USER", "KVM_TEST_SSH_KEY", "KVM_TEST_BRIDGE", "KVM_TEST_IMAGE_URL"} {
|
|
v := os.Getenv(key)
|
|
if v == "" {
|
|
t.Skipf("%s not set; skipping KVM integration test", key)
|
|
}
|
|
required[key] = v
|
|
}
|
|
cfg := &provider.KVMConfig{
|
|
SSHKeyFile: required["KVM_TEST_SSH_KEY"],
|
|
KnownHostsFile: os.Getenv("KVM_TEST_KNOWN_HOSTS"),
|
|
Images: map[string]provider.KVMImage{
|
|
integrationImageKey: {URL: required["KVM_TEST_IMAGE_URL"], SHA256: os.Getenv("KVM_TEST_IMAGE_SHA256")},
|
|
},
|
|
MachineTypes: map[string]provider.KVMMachineType{"small": {VCPUs: 1, MemoryMiB: 1024}},
|
|
Hosts: []provider.KVMHost{{
|
|
Name: integrationHostName,
|
|
Address: required["KVM_TEST_HOST"],
|
|
User: required["KVM_TEST_USER"],
|
|
Socket: os.Getenv("KVM_TEST_SOCKET"),
|
|
Pool: os.Getenv("KVM_TEST_POOL"),
|
|
Bridge: required["KVM_TEST_BRIDGE"],
|
|
}},
|
|
}
|
|
if cfg.KnownHostsFile == "" {
|
|
cfg.InsecureIgnoreHostKey = true
|
|
t.Log("KVM_TEST_KNOWN_HOSTS not set: host key verification disabled for this run")
|
|
}
|
|
pc := provider.ProviderConfig{Name: "kvm-integration", Type: "kvm", KVM: cfg}
|
|
timeout := 20 * time.Minute
|
|
if v := os.Getenv("KVM_TEST_TIMEOUT"); v != "" {
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
t.Fatalf("KVM_TEST_TIMEOUT: %v", err)
|
|
}
|
|
timeout = d
|
|
}
|
|
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 {
|
|
t.Helper()
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := hex.EncodeToString(b[:])
|
|
return fmt.Sprintf("%s-%s-%s-%s-%s", h[0:8], h[8:12], h[12:16], h[16:20], h[20:32])
|
|
}
|
|
|
|
// TestKVM_lifecycle is one sequential story, not parallel subtests: each
|
|
// stage depends on the state the previous one left on the hypervisor.
|
|
func TestKVM_lifecycle(t *testing.T) {
|
|
env := loadIntegrationEnv(t)
|
|
log := integrationLogger(t, env.logLevel)
|
|
ctx, cancel := context.WithTimeout(logr.NewContext(context.Background(), log), env.timeout)
|
|
defer cancel()
|
|
|
|
prov, err := New(ctx, env.cfg)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
p := prov.(*Provider)
|
|
host := p.cfg.Hosts[0]
|
|
|
|
uid := randomUID(t)
|
|
req := provider.CreateRequest{
|
|
Name: provider.NameFromUID(types.UID(uid)),
|
|
UID: uid,
|
|
Namespace: "integration",
|
|
ProxyName: "kvm-integration",
|
|
Placement: provider.Placement{Zone: integrationHostName, MachineType: "small", Image: integrationImageKey},
|
|
CloudInit: "#cloud-config\n",
|
|
Port: 3128,
|
|
}
|
|
t.Logf("host %s (%s), pool %q, bridge %s, domain %s", host.Name, host.Address, host.Pool, host.Bridge, req.Name)
|
|
|
|
// Cleanup runs even when a stage fails, with its own budget: the test
|
|
// ctx may already be spent by then.
|
|
t.Cleanup(func() {
|
|
if env.keep {
|
|
t.Logf("KVM_TEST_KEEP set: leaving domain %s and its volumes on %s", req.Name, host.Address)
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
|
|
// --- create (includes the image fetch on a cold host) ---
|
|
start := time.Now()
|
|
id, err := p.Create(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
t.Logf("Create returned %s after %s", id, time.Since(start).Round(time.Second))
|
|
if want := formatProviderID(host.Name, req.Name); id != want {
|
|
t.Fatalf("providerID = %s, want %s", id, want)
|
|
}
|
|
|
|
// --- create again: must find what it made, not duplicate it ---
|
|
id2, err := p.Create(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("second Create: %v", err)
|
|
}
|
|
if id2 != id {
|
|
t.Fatalf("second Create returned %s, want %s", id2, id)
|
|
}
|
|
|
|
// --- wait for the guest to come up with an address ---
|
|
inst := waitForRunning(ctx, t, p, id)
|
|
t.Logf("domain Running with IP %s (state reported after %s)", inst.IP, time.Since(start).Round(time.Second))
|
|
if inst.UID != uid {
|
|
t.Errorf("UID read back = %q, want %q (metadata round-trip through libvirt)", inst.UID, uid)
|
|
}
|
|
if age := time.Since(inst.CreatedAt); inst.CreatedAt.IsZero() || age < 0 || age > time.Hour {
|
|
t.Errorf("CreatedAt = %s (age %s), want a recent timestamp", inst.CreatedAt, age)
|
|
}
|
|
if conn, err := net.DialTimeout("tcp", net.JoinHostPort(inst.IP, "22"), 5*time.Second); err != nil {
|
|
t.Logf("note: tcp %s:22 not reachable from this machine (%v); fine if the bridge network is not routed here", inst.IP, err)
|
|
} else {
|
|
conn.Close()
|
|
t.Logf("tcp %s:22 reachable from this machine", inst.IP)
|
|
}
|
|
|
|
// --- the GC view must include it ---
|
|
listed, err := p.ListByTag(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListByTag: %v", err)
|
|
}
|
|
if !containsID(listed, id) {
|
|
t.Errorf("ListByTag = %+v, want it to include %s", listed, id)
|
|
}
|
|
|
|
// --- delete, then verify nothing of the VM remains but the base image does ---
|
|
if err := p.Delete(ctx, id); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
if _, err := p.Get(ctx, id); !errors.Is(err, provider.ErrNotFound) {
|
|
t.Errorf("Get after Delete = %v, want ErrNotFound", err)
|
|
}
|
|
if err := p.Delete(ctx, id); err != nil {
|
|
t.Errorf("second Delete: %v, want nil (idempotent)", err)
|
|
}
|
|
listed, err = p.ListByTag(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListByTag after Delete: %v", err)
|
|
}
|
|
if containsID(listed, id) {
|
|
t.Errorf("ListByTag still includes %s after Delete", id)
|
|
}
|
|
|
|
src, err := resolveImage(p.cfg, integrationImageKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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
|
|
}
|
|
for _, gone := range []string{overlayVolumeName(req.Name), seedVolumeName(req.Name)} {
|
|
if _, err := api.StorageVolLookupByName(pool, gone); !hasCode(err, libvirt.ErrNoStorageVol) {
|
|
t.Errorf("volume %s after Delete: lookup err = %v, want ErrNoStorageVol", gone, err)
|
|
}
|
|
}
|
|
for _, kept := range []string{src.volumeName(), src.markerName()} {
|
|
if _, err := api.StorageVolLookupByName(pool, kept); err != nil {
|
|
t.Errorf("cached image volume %s: %v, want it kept after Delete", kept, err)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("inspecting pool after Delete: %v", err)
|
|
}
|
|
// KVM_TEST_KEEP is for inspecting a failed run; a passed run has
|
|
// nothing left to keep, so let cleanup's Delete confirm that.
|
|
env.keep = false
|
|
}
|
|
|
|
// waitForRunning polls Get until the instance is Running with an IP or ctx
|
|
// ends. Transient errors are retried; anything else fails the test.
|
|
func waitForRunning(ctx context.Context, t *testing.T, p *Provider, id string) *provider.Instance {
|
|
t.Helper()
|
|
var last provider.InstanceState
|
|
ticker := time.NewTicker(integrationPoll)
|
|
defer ticker.Stop()
|
|
for {
|
|
inst, err := p.Get(ctx, id)
|
|
switch {
|
|
case err == nil:
|
|
if inst.State != last {
|
|
t.Logf("state %s ip %q", inst.State, inst.IP)
|
|
last = inst.State
|
|
}
|
|
if inst.State == provider.StateRunning {
|
|
return inst
|
|
}
|
|
if inst.State == provider.StateStopped {
|
|
t.Fatalf("domain reported %s while waiting for Running; check `virsh console %s` on the host", inst.State, idName(id))
|
|
}
|
|
case errors.Is(err, provider.ErrTransient):
|
|
t.Logf("Get: transient error, retrying: %v", err)
|
|
default:
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatalf("timed out waiting for Running (last state %s): %v", last, ctx.Err())
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func idName(id string) string {
|
|
_, name, _ := parseProviderID(id)
|
|
return name
|
|
}
|
|
|
|
func containsID(list []provider.Instance, id string) bool {
|
|
for _, inst := range list {
|
|
if inst.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|