baseVolumeName was base-<key>.<format>, byte-identical to what the kvm provider builds in the same pool -- for URL images too, since kvm takes hex(sum[:8]) and kvm2 takes hex(sum[:])[:16], the same eight bytes -- with a completely separate lock table. The shipped config has a live kvm-home entry and a commented-out kvm2-home entry on the same host, the same key debian-13 and the same default pool, and the comment invites the operator to enable it. Both integration tests use key test-image. On beelink both live overlays back onto kvm's base-debian-13.qcow2. Since cachedImage accepts any 64-hex marker when no sha256 is configured and never re-hashes the base, that meant two entries pointing one key at different URLs silently shared a file; a concurrent cold fetch let one entry delete the other's half-uploaded base and boot a guest from the remains; and a sha256 edit deleted the base in place under live overlays, which libvirt permits without complaint. The name now carries the provider entry: base-<entry>-<key>.<format>. imageSource gained an Entry field and resolveImage takes the provider name, so every production call site follows from imageSource.volumeName(). That closes the cross-entry sharing outright, kvm<->kvm2 and kvm2<->kvm2 alike, and turns the per-Provider imageLocks from a latent bug into a correct one: with names unique per entry, two entries cannot contend for a file, so no process-wide lock table is needed. The entry name now reaches a file name, so New checks it against the existing kvmIdentifier rule, exported as provider.IsKVMIdentifier rather than duplicated. Provider names are otherwise validated for all types at once and only for being non-empty and unique, so a name with a slash would have produced a volume nothing could address. One companion fix, because the naming alone does not stop it: cachedImage's digest-mismatch branch deleted the marker and the base and refetched. It now returns ErrPermanent naming both digests and telling the operator what to do. That branch was the remaining path that unlinks a good file under running domains, and the contract already said "add a new key instead" -- the code was contradicting it. The no-marker and non-hex-marker branches keep deleting, since there the base is debris. Not fixed, and said so in the docs rather than left to look closed: editing images.<key>.url is still a silent no-op, since nothing records the URL; and cross-process fencing still needs the deferred atomic publish (upload to a temp name, verify, publish), which lands where H2 put the discard. Migration is a non-event. The base-debian-13.qcow2 on beelink is kvm's, kvm keeps using it untouched, and kvm2 downloads its own copy once per host. Nothing is replaced under a running guest; the pool needs room for both, which the prerequisites now say. The eviction recipe in the guide named base-debian-13.qcow2, which after this change is kvm's file -- following it would have deleted the wrong provider's base. It now greps for the prefix and deletes the marker first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
591 lines
20 KiB
Go
591 lines
20 KiB
Go
//go:build integration
|
|
|
|
package kvm2
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/digitalocean/go-libvirt"
|
|
"github.com/go-logr/logr"
|
|
"github.com/go-logr/logr/funcr"
|
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
|
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
|
|
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
|
|
)
|
|
|
|
// The real-hypervisor test of spec entry 21. It runs only with
|
|
// -tags=integration and skips itself unless KVM2_TEST_* names a host; see
|
|
// docs/testing.md for the variables and what one run asserts.
|
|
const (
|
|
integrationProvider = "kvm2-integration"
|
|
integrationHost = "kvm2-test"
|
|
integrationImage = "test-image"
|
|
integrationMachine = "small"
|
|
integrationPort = 3128
|
|
|
|
// integrationCloudInit is the user-data the VM boots with. Nothing
|
|
// asserts on the guest running it; it exists so the sentinel check can
|
|
// prove that no span attribute carries the cloud-init text.
|
|
integrationCloudInit = "#cloud-config\nruncmd:\n - echo kvm2-integration\n"
|
|
|
|
defaultIntegrationTimeout = 20 * time.Minute
|
|
defaultIntegrationLogLvl = 1
|
|
getPollInterval = 5 * time.Second
|
|
cleanupBudget = 5 * time.Minute
|
|
)
|
|
|
|
type integrationEnv struct {
|
|
host, user, sshKey, knownHosts, bridge, imageURL string
|
|
imageSHA256, pool, socket string
|
|
timeout time.Duration
|
|
keep bool
|
|
logLevel int
|
|
}
|
|
|
|
// loadIntegrationEnv reads the KVM2_TEST_* variables, skipping the test
|
|
// under -short or when a required one is missing.
|
|
func loadIntegrationEnv(t *testing.T) integrationEnv {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("real-hypervisor test skipped under -short")
|
|
}
|
|
env := integrationEnv{timeout: defaultIntegrationTimeout, logLevel: defaultIntegrationLogLvl}
|
|
required := []struct {
|
|
name string
|
|
dst *string
|
|
}{
|
|
{"KVM2_TEST_HOST", &env.host},
|
|
{"KVM2_TEST_USER", &env.user},
|
|
{"KVM2_TEST_SSH_KEY", &env.sshKey},
|
|
{"KVM2_TEST_BRIDGE", &env.bridge},
|
|
{"KVM2_TEST_IMAGE_URL", &env.imageURL},
|
|
}
|
|
for _, r := range required {
|
|
v := os.Getenv(r.name)
|
|
if v == "" {
|
|
t.Skipf("%s is not set; the kvm2 integration test needs a real hypervisor (see docs/testing.md)", r.name)
|
|
}
|
|
*r.dst = v
|
|
}
|
|
env.knownHosts = os.Getenv("KVM2_TEST_KNOWN_HOSTS")
|
|
env.imageSHA256 = os.Getenv("KVM2_TEST_IMAGE_SHA256")
|
|
env.pool = os.Getenv("KVM2_TEST_POOL")
|
|
env.socket = os.Getenv("KVM2_TEST_SOCKET")
|
|
env.keep = os.Getenv("KVM2_TEST_KEEP") != ""
|
|
if v := os.Getenv("KVM2_TEST_TIMEOUT"); v != "" {
|
|
d, err := time.ParseDuration(v)
|
|
if err != nil {
|
|
t.Fatalf("KVM2_TEST_TIMEOUT=%q: %v", v, err)
|
|
}
|
|
env.timeout = d
|
|
}
|
|
if v := os.Getenv("KVM2_TEST_LOG_LEVEL"); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 0 {
|
|
t.Fatalf("KVM2_TEST_LOG_LEVEL=%q: want a non-negative integer", v)
|
|
}
|
|
env.logLevel = n
|
|
}
|
|
return env
|
|
}
|
|
|
|
// providerConfig is what the operator would load for this host: one host,
|
|
// one image, one machine type. Host keys are verified only when
|
|
// KVM2_TEST_KNOWN_HOSTS is set.
|
|
func (e integrationEnv) providerConfig() provider.ProviderConfig {
|
|
return provider.ProviderConfig{
|
|
Name: integrationProvider,
|
|
Type: "kvm2",
|
|
KVM2: &provider.KVMConfig{
|
|
SSHKeyFile: e.sshKey,
|
|
KnownHostsFile: e.knownHosts,
|
|
InsecureIgnoreHostKey: e.knownHosts == "",
|
|
Images: map[string]provider.KVMImage{
|
|
integrationImage: {URL: e.imageURL, SHA256: e.imageSHA256},
|
|
},
|
|
MachineTypes: map[string]provider.KVMMachineType{
|
|
integrationMachine: {VCPUs: 1, MemoryMiB: 1024},
|
|
},
|
|
Hosts: []provider.KVMHost{{
|
|
Name: integrationHost,
|
|
Address: e.host,
|
|
User: e.user,
|
|
Socket: e.socket,
|
|
Pool: e.pool,
|
|
Bridge: e.bridge,
|
|
}},
|
|
},
|
|
}
|
|
}
|
|
|
|
// integrationLog routes the provider's logr output into t.Log through funcr
|
|
// at the chosen verbosity, keeps every line for assertions, and strips the
|
|
// key/value pairs that are constant for the run (stated once up front) so
|
|
// each line stays short. Lines arriving after the test ended are dropped:
|
|
// t.Log panics then.
|
|
type integrationLog struct {
|
|
t *testing.T
|
|
|
|
mu sync.Mutex
|
|
drop []string
|
|
lines []string
|
|
done bool
|
|
}
|
|
|
|
func newIntegrationLog(t *testing.T, level int, drop ...string) (*integrationLog, logr.Logger) {
|
|
t.Helper()
|
|
sink := &integrationLog{t: t, drop: drop}
|
|
t.Cleanup(func() {
|
|
sink.mu.Lock()
|
|
defer sink.mu.Unlock()
|
|
sink.done = true
|
|
})
|
|
log := funcr.New(func(prefix, args string) { sink.write(prefix, args) }, funcr.Options{Verbosity: level})
|
|
return sink, log
|
|
}
|
|
|
|
// dropValue adds a rendered pair (`"key"="value"`) to strip from later lines.
|
|
func (l *integrationLog) dropValue(key, value string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.drop = append(l.drop, fmt.Sprintf("%q=%q", key, value))
|
|
}
|
|
|
|
func (l *integrationLog) write(prefix, args string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.lines = append(l.lines, args)
|
|
if l.done {
|
|
return
|
|
}
|
|
for _, d := range l.drop {
|
|
args = strings.ReplaceAll(args, " "+d, "")
|
|
}
|
|
l.t.Logf("%s %s %s", time.Now().Format("15:04:05.000"), prefix, args)
|
|
}
|
|
|
|
func (l *integrationLog) matching(substr string) []string {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
var out []string
|
|
for _, line := range l.lines {
|
|
if strings.Contains(line, substr) {
|
|
out = append(out, line)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// newIntegrationProvider builds the provider the way New does, plus the span
|
|
// recorder's tracer on both places the package starts spans: the Provider
|
|
// (kvm2.download) and the dialer (kvm2.dial and, through observe, every
|
|
// kvm2.<Method>). newLibvirtDialer has no option for it, so the field is set
|
|
// directly; that is what makes the kvm2.* spans land in the same recorder
|
|
// as provider.WithTracing's provider.* spans.
|
|
func newIntegrationProvider(pc provider.ProviderConfig, tp *sdktrace.TracerProvider) *Provider {
|
|
cfg := withDefaults(*pc.KVM2)
|
|
opt := tracing.WithTracerProvider(tp)
|
|
d := newLibvirtDialer(pc.Name, cfg)
|
|
d.tracing = []tracing.Option{opt}
|
|
return newProvider(pc.Name, cfg, d, withTracing(opt))
|
|
}
|
|
|
|
// randomUID is a UUID-shaped string, so the run's domain never collides with
|
|
// a previous run's leftovers or with a real Proxy's.
|
|
func randomUID(t *testing.T) string {
|
|
t.Helper()
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
t.Fatalf("reading random bytes: %v", err)
|
|
}
|
|
h := hex.EncodeToString(b[:])
|
|
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
|
|
}
|
|
|
|
// stepDone logs one lifecycle step with its duration; the log is the record
|
|
// of how long a cold image download or a boot took on this host.
|
|
func stepDone(t *testing.T, step string, start time.Time) {
|
|
t.Helper()
|
|
t.Logf("step %s: %s", step, time.Since(start).Round(time.Millisecond))
|
|
}
|
|
|
|
// waitUntil polls cond every interval until it is true or ctx ends.
|
|
func waitUntil(ctx context.Context, t *testing.T, interval time.Duration, what string, cond func() bool) {
|
|
t.Helper()
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for !cond() {
|
|
select {
|
|
case <-ticker.C:
|
|
case <-ctx.Done():
|
|
t.Fatalf("gave up waiting for %s: %v", what, ctx.Err())
|
|
}
|
|
}
|
|
}
|
|
|
|
// domainCount lists every domain on the host over a dedicated connection.
|
|
func domainCount(ctx context.Context, t *testing.T, p *Provider, host provider.KVMHost) int {
|
|
t.Helper()
|
|
be, err := p.hosts.dialFresh(ctx, host)
|
|
if err != nil {
|
|
t.Fatalf("dialling %s to count domains: %v", host.Name, err)
|
|
}
|
|
defer func() { _ = be.Close() }()
|
|
doms, _, err := be.ConnectListAllDomains(ctx, 1, libvirt.ConnectListDomainsActive|libvirt.ConnectListDomainsInactive)
|
|
if err != nil {
|
|
t.Fatalf("listing domains on %s: %v", host.Name, err)
|
|
}
|
|
return len(doms)
|
|
}
|
|
|
|
// poolVolumes reports, for each name, whether the host's pool holds a volume
|
|
// of that name, over a dedicated connection. Any error other than
|
|
// ErrNoStorageVol fails the test.
|
|
func poolVolumes(ctx context.Context, t *testing.T, p *Provider, host provider.KVMHost, names ...string) map[string]bool {
|
|
t.Helper()
|
|
be, err := p.hosts.dialFresh(ctx, host)
|
|
if err != nil {
|
|
t.Fatalf("dialling %s to inspect the pool: %v", host.Name, err)
|
|
}
|
|
defer func() { _ = be.Close() }()
|
|
pool, err := be.StoragePoolLookupByName(ctx, host.Pool)
|
|
if err != nil {
|
|
t.Fatalf("looking up pool %s: %v", host.Pool, err)
|
|
}
|
|
out := make(map[string]bool, len(names))
|
|
for _, name := range names {
|
|
_, err := be.StorageVolLookupByName(ctx, pool, name)
|
|
switch {
|
|
case err == nil:
|
|
out[name] = true
|
|
case hasCode(err, libvirt.ErrNoStorageVol):
|
|
out[name] = false
|
|
default:
|
|
t.Fatalf("looking up volume %s in pool %s: %v", name, host.Pool, err)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestKVM2_lifecycle(t *testing.T) {
|
|
env := loadIntegrationEnv(t)
|
|
pc := env.providerConfig()
|
|
sink, log := newIntegrationLog(t, env.logLevel,
|
|
fmt.Sprintf("%q=%q", "provider", integrationProvider),
|
|
fmt.Sprintf("%q=%q", "host", integrationHost))
|
|
ctx, cancel := context.WithTimeout(logf.IntoContext(context.Background(), log), env.timeout)
|
|
defer cancel()
|
|
|
|
t.Logf("provider=%s host=%s address=%s user=%s bridge=%s image=%s budget=%s log level %d; provider, host and name are left off every provider log line",
|
|
integrationProvider, integrationHost, env.host, env.user, env.bridge, env.imageURL, env.timeout, env.logLevel)
|
|
if env.knownHosts == "" {
|
|
t.Log("KVM2_TEST_KNOWN_HOSTS is not set: host keys are NOT verified for this run")
|
|
}
|
|
|
|
// 1. New returns without dialling.
|
|
start := time.Now()
|
|
built, err := New(ctx, pc)
|
|
elapsed := time.Since(start)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
if elapsed > 100*time.Millisecond {
|
|
t.Errorf("New() took %s, want under 100ms: it must not dial", elapsed)
|
|
}
|
|
if lines := sink.matching("libvirt host connected"); len(lines) > 0 {
|
|
t.Errorf("New() dialled the host before any call: %q", lines)
|
|
}
|
|
stepDone(t, "1 New", start)
|
|
|
|
// 2. Check reports the host Ready with details.
|
|
checker, ok := built.(provider.Checker)
|
|
if !ok {
|
|
t.Fatalf("New() returned %T, which is not a provider.Checker", built)
|
|
}
|
|
start = time.Now()
|
|
targets := checker.Check(ctx)
|
|
if len(targets) != 1 {
|
|
t.Fatalf("Check() returned %d targets, want 1: %+v", len(targets), targets)
|
|
}
|
|
ts := targets[0]
|
|
if !ts.Ready || ts.Reason != provider.ReasonReachable {
|
|
t.Fatalf("Check() = ready %v reason %q message %q, want Ready/%s (details %v)", ts.Ready, ts.Reason, ts.Message, provider.ReasonReachable, ts.Details)
|
|
}
|
|
for _, key := range []string{"hostname", "libvirtVersion", "poolState", "poolAvailableGiB"} {
|
|
if ts.Details[key] == "" {
|
|
t.Errorf("Check() details lack %q: %v", key, ts.Details)
|
|
}
|
|
}
|
|
if ts.Details["poolState"] != "running" {
|
|
t.Errorf("Check() poolState = %q, want running", ts.Details["poolState"])
|
|
}
|
|
t.Logf("host %s: hostname=%s libvirt=%s pool=%s (%s) available=%s GiB capacity=%s GiB latency=%s",
|
|
ts.Name, ts.Details["hostname"], ts.Details["libvirtVersion"], ts.Details["pool"], ts.Details["poolState"],
|
|
ts.Details["poolAvailableGiB"], ts.Details["poolCapacityGiB"], ts.Latency.Round(time.Millisecond))
|
|
stepDone(t, "2 Check", start)
|
|
|
|
// 3. Every later call goes through the tracing decorator, with the
|
|
// package's own spans recorded alongside.
|
|
sr := tracetest.NewSpanRecorder()
|
|
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr))
|
|
t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
|
|
p := newIntegrationProvider(pc, tp)
|
|
traced := provider.WithTracing(pc.Name, p, tracing.WithTracerProvider(tp))
|
|
host := p.cfg.Hosts[0]
|
|
|
|
// 4. Create.
|
|
uid := randomUID(t)
|
|
name := provider.NameFromUID(types.UID(uid))
|
|
sink.dropValue("name", name)
|
|
id := formatProviderID(integrationHost, name)
|
|
req := provider.CreateRequest{
|
|
Name: name,
|
|
UID: uid,
|
|
Namespace: "integration",
|
|
ProxyName: "kvm2-lifecycle",
|
|
Placement: provider.Placement{Zone: integrationHost, MachineType: integrationMachine, Image: integrationImage},
|
|
CloudInit: integrationCloudInit,
|
|
Port: integrationPort,
|
|
}
|
|
t.Logf("domain name %s, uid %s, id %s", name, uid, id)
|
|
|
|
deleted := false
|
|
if env.keep {
|
|
t.Logf("KVM2_TEST_KEEP is set: the VM stays behind. Clean up on the host with:\n"+
|
|
" virsh undefine --nvram %s\n virsh vol-delete --pool %s %s\n virsh vol-delete --pool %s %s",
|
|
name, host.Pool, overlayVolumeName(name), host.Pool, seedVolumeName(name))
|
|
} else {
|
|
t.Cleanup(func() {
|
|
if deleted {
|
|
return
|
|
}
|
|
cctx, ccancel := context.WithTimeout(logf.IntoContext(context.Background(), log), cleanupBudget)
|
|
defer ccancel()
|
|
if err := p.Delete(cctx, id); err != nil {
|
|
t.Errorf("cleanup Delete(%s) error = %v; remove the domain and its volumes by hand", id, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
start = time.Now()
|
|
gotID, err := traced.Create(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("Create() error = %v", err)
|
|
}
|
|
if gotID != id {
|
|
t.Fatalf("Create() = %q, want %q", gotID, id)
|
|
}
|
|
createReturned := time.Now()
|
|
stepDone(t, "4 Create (includes the image download on a cold pool)", start)
|
|
|
|
// 5. Create again: same ID, nothing new on the host.
|
|
start = time.Now()
|
|
before := domainCount(ctx, t, p, host)
|
|
again, err := traced.Create(ctx, req)
|
|
if err != nil {
|
|
t.Fatalf("second Create() error = %v", err)
|
|
}
|
|
if again != id {
|
|
t.Errorf("second Create() = %q, want %q", again, id)
|
|
}
|
|
if after := domainCount(ctx, t, p, host); after != before {
|
|
t.Errorf("second Create() changed the host's domain count from %d to %d", before, after)
|
|
}
|
|
stepDone(t, "5 Create again", start)
|
|
|
|
// 6. Get reaches Running with an IPv4.
|
|
start = time.Now()
|
|
var inst *provider.Instance
|
|
waitUntil(ctx, t, getPollInterval, "the domain to be Running with an IPv4", func() bool {
|
|
var err error
|
|
inst, err = traced.Get(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("Get(%s) error = %v", id, err)
|
|
}
|
|
return inst.State == provider.StateRunning && inst.IP != ""
|
|
})
|
|
if inst.UID != uid {
|
|
t.Errorf("Get() UID = %q, want %q", inst.UID, uid)
|
|
}
|
|
if inst.CreatedAt.IsZero() {
|
|
t.Error("Get() CreatedAt is zero; the metadata mark did not round-trip")
|
|
} else if skew := time.Since(inst.CreatedAt).Abs(); skew > 5*time.Minute {
|
|
t.Errorf("Get() CreatedAt = %s, %s from now; want within a few minutes", inst.CreatedAt, skew)
|
|
}
|
|
t.Logf("domain running at %s, %s after Create returned (createdAt %s)", inst.IP, time.Since(createReturned).Round(time.Millisecond), inst.CreatedAt.Format(time.RFC3339))
|
|
stepDone(t, "6 Get until Running", start)
|
|
|
|
// 7. ListByTag contains it.
|
|
start = time.Now()
|
|
list, err := traced.ListByTag(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListByTag() error = %v", err)
|
|
}
|
|
found := false
|
|
for _, li := range list {
|
|
if li.ID == id {
|
|
found = true
|
|
if li.UID != uid {
|
|
t.Errorf("ListByTag() entry %s has UID %q, want %q", id, li.UID, uid)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("ListByTag() = %d instances, none with ID %s", len(list), id)
|
|
}
|
|
stepDone(t, "7 ListByTag", start)
|
|
|
|
// 8. Reachability, logged only: this machine may not route to the bridge.
|
|
start = time.Now()
|
|
addr := net.JoinHostPort(inst.IP, "22")
|
|
if conn, err := net.DialTimeout("tcp", addr, 3*time.Second); err != nil {
|
|
t.Logf("TCP connect to %s failed (informational; the test host may not route to the bridge): %v", addr, err)
|
|
} else {
|
|
_ = conn.Close()
|
|
t.Logf("TCP connect to %s succeeded", addr)
|
|
}
|
|
stepDone(t, "8 TCP connect", start)
|
|
|
|
if env.keep {
|
|
t.Log("KVM2_TEST_KEEP is set: skipping Delete and the pool inspection")
|
|
} else {
|
|
// 9. Delete, Get is NotFound, Delete again.
|
|
start = time.Now()
|
|
if err := traced.Delete(ctx, id); err != nil {
|
|
t.Fatalf("Delete() error = %v", err)
|
|
}
|
|
deleted = true
|
|
if _, err := traced.Get(ctx, id); !errors.Is(err, provider.ErrNotFound) {
|
|
t.Errorf("Get() after Delete error = %v, want ErrNotFound", err)
|
|
}
|
|
if err := traced.Delete(ctx, id); err != nil {
|
|
t.Errorf("second Delete() error = %v, want nil", err)
|
|
}
|
|
stepDone(t, "9 Delete, Get, Delete", start)
|
|
|
|
// 10. The pool keeps the shared base image and its marker, and holds
|
|
// no per-VM volume.
|
|
start = time.Now()
|
|
base := baseVolumeName(integrationProvider, integrationImage, provider.KVMFormatQcow2)
|
|
marker := markerVolumeName(base)
|
|
overlay, seed := overlayVolumeName(name), seedVolumeName(name)
|
|
present := poolVolumes(ctx, t, p, host, base, marker, overlay, seed)
|
|
for _, want := range []string{base, marker} {
|
|
if !present[want] {
|
|
t.Errorf("pool %s lacks %s after Delete; the base image cache must stay", host.Pool, want)
|
|
}
|
|
}
|
|
for _, gone := range []string{overlay, seed} {
|
|
if present[gone] {
|
|
t.Errorf("pool %s still holds %s after Delete", host.Pool, gone)
|
|
}
|
|
}
|
|
stepDone(t, "10 pool inspection", start)
|
|
}
|
|
|
|
// 11. Spans: the package's spans are children of the decorator's.
|
|
assertLifecycleSpans(t, sr.Ended())
|
|
}
|
|
|
|
// assertLifecycleSpans checks that at least one provider.create span has a
|
|
// kvm2.DomainDefineXML and a kvm2.dial child (the first Create dials the
|
|
// pooled connection and defines the domain), and that no attribute of any
|
|
// span carries the cloud-init text.
|
|
func assertLifecycleSpans(t *testing.T, spans []sdktrace.ReadOnlySpan) {
|
|
t.Helper()
|
|
var creates []sdktrace.ReadOnlySpan
|
|
for _, s := range spans {
|
|
if s.Name() == "provider.create" {
|
|
creates = append(creates, s)
|
|
}
|
|
}
|
|
if len(creates) == 0 {
|
|
t.Fatalf("no provider.create span among %d spans: %v", len(spans), spanNames(spans))
|
|
}
|
|
satisfied := false
|
|
for _, create := range creates {
|
|
parent := create.SpanContext().SpanID()
|
|
var children, define, dial int
|
|
for _, s := range spans {
|
|
if s.Parent().SpanID() != parent || !strings.HasPrefix(s.Name(), "kvm2.") {
|
|
continue
|
|
}
|
|
children++
|
|
switch s.Name() {
|
|
case "kvm2.DomainDefineXML":
|
|
define++
|
|
case "kvm2.dial":
|
|
dial++
|
|
}
|
|
}
|
|
t.Logf("provider.create span %s: %d kvm2.* children (%d DomainDefineXML, %d dial)", parent, children, define, dial)
|
|
if define > 0 && dial > 0 {
|
|
satisfied = true
|
|
}
|
|
}
|
|
if !satisfied {
|
|
t.Errorf("no provider.create span has both a kvm2.DomainDefineXML and a kvm2.dial child; spans: %v", spanNames(spans))
|
|
}
|
|
for _, s := range spans {
|
|
for _, a := range s.Attributes() {
|
|
v := a.Value.Emit()
|
|
if strings.Contains(v, integrationCloudInit) || strings.Contains(v, "#cloud-config") {
|
|
t.Errorf("span %s attribute %s carries the cloud-init text", s.Name(), a.Key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestKVM2Check_realHost runs the health check against the host and creates
|
|
// nothing.
|
|
func TestKVM2Check_realHost(t *testing.T) {
|
|
env := loadIntegrationEnv(t)
|
|
pc := env.providerConfig()
|
|
_, log := newIntegrationLog(t, env.logLevel,
|
|
fmt.Sprintf("%q=%q", "provider", integrationProvider),
|
|
fmt.Sprintf("%q=%q", "host", integrationHost))
|
|
ctx, cancel := context.WithTimeout(logf.IntoContext(context.Background(), log), env.timeout)
|
|
defer cancel()
|
|
if env.knownHosts == "" {
|
|
t.Log("KVM2_TEST_KNOWN_HOSTS is not set: host keys are NOT verified for this run")
|
|
}
|
|
|
|
built, err := New(ctx, pc)
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
checker, ok := built.(provider.Checker)
|
|
if !ok {
|
|
t.Fatalf("New() returned %T, which is not a provider.Checker", built)
|
|
}
|
|
targets := checker.Check(ctx)
|
|
if len(targets) != 1 {
|
|
t.Fatalf("Check() returned %d targets, want 1: %+v", len(targets), targets)
|
|
}
|
|
ts := targets[0]
|
|
if !ts.Ready || ts.Reason != provider.ReasonReachable {
|
|
t.Fatalf("Check() = ready %v reason %q message %q, want Ready/%s (details %v)", ts.Ready, ts.Reason, ts.Message, provider.ReasonReachable, ts.Details)
|
|
}
|
|
if ts.Details["poolState"] != "running" {
|
|
t.Errorf("Check() poolState = %q, want running", ts.Details["poolState"])
|
|
}
|
|
t.Logf("host %s (%s): hostname=%s libvirt=%s pool=%s (%s) available=%s GiB latency=%s",
|
|
ts.Name, ts.Address, ts.Details["hostname"], ts.Details["libvirtVersion"], ts.Details["pool"], ts.Details["poolState"],
|
|
ts.Details["poolAvailableGiB"], ts.Latency.Round(time.Millisecond))
|
|
}
|