The contract sweep in provider_test.go: every method and Check returns within a second of a cancel, every wrapErr path carries the configured name, op and id, the V0/V1/V2 logging tiers and the no-op lines, and a Get over the real client whose HTTP spans are children of provider.get. proxmox_integration_test.go (-tags=integration) holds TestProxmox_lifecycle and TestProxmoxCheck_realHost; both compile and skip without PROXMOX_TEST_*, and have not run against a host yet. make test-integration-proxmox runs them. docs/testing.md gains the proxmox, nocloud and keyedlock rows, the Live Proxmox gaps and the integration section; spec entry 21's section 11 list is ticked. Waits that could hang the package under a mutation are bounded: the concurrent-Create loop and eight direct blocking calls in older tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
699 lines
24 KiB
Go
699 lines
24 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
|
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
|
|
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
|
|
)
|
|
|
|
const (
|
|
testProvider = "proxmox-home"
|
|
testUID = "0b9c6f3e-2d4a-4e1b-9c7f-5a8d3e2f1b60"
|
|
testVMID = 150042
|
|
)
|
|
|
|
var (
|
|
testName = provider.NameFromUID(testUID)
|
|
testCreatedAt = time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC)
|
|
)
|
|
|
|
// fixture is a Provider over fakeAPI, logging into a capture, with every
|
|
// budget shrunk so waits take milliseconds.
|
|
type fixture struct {
|
|
t *testing.T
|
|
p *Provider
|
|
fake *fakeAPI
|
|
logs *logCapture
|
|
ctx context.Context
|
|
}
|
|
|
|
func newFixture(t *testing.T, verbosity int) *fixture {
|
|
t.Helper()
|
|
fx := &fixture{t: t, fake: newFakeAPI(), logs: &logCapture{}}
|
|
fx.p = newProvider(testProvider, testConfig(), fx.fake)
|
|
fx.p.budgets = testBudgets()
|
|
fx.ctx = fx.logs.context(verbosity)
|
|
return fx
|
|
}
|
|
|
|
func testBudgets() budgets {
|
|
return budgets{
|
|
clone: time.Second,
|
|
upload: time.Second,
|
|
resize: time.Second,
|
|
start: time.Second,
|
|
stop: time.Second,
|
|
destroy: time.Second,
|
|
seedDelete: time.Second,
|
|
running: time.Second,
|
|
poll: time.Millisecond,
|
|
}
|
|
}
|
|
|
|
// addOurVM places a VM carrying the mark for testUID and the tag, as
|
|
// Create leaves it.
|
|
func (fx *fixture) addOurVM(vmid int, name, status string) *fakeVM {
|
|
fx.t.Helper()
|
|
vm := fx.fake.addVM(vmid, name, status)
|
|
vm.config["description"] = formatDescription(testUID, testCreatedAt)
|
|
vm.config["tags"] = proxyTag
|
|
return vm
|
|
}
|
|
|
|
// id is the providerID of the VM addOurVM(testVMID, testName, …) places.
|
|
func (fx *fixture) id() string { return formatProviderID(testVMID, testName) }
|
|
|
|
func (fx *fixture) get(id string) *provider.Instance {
|
|
fx.t.Helper()
|
|
inst, err := fx.p.Get(fx.ctx, id)
|
|
if err != nil {
|
|
fx.t.Fatalf("Get(%s) error = %v", id, err)
|
|
}
|
|
return inst
|
|
}
|
|
|
|
// assertProviderError checks that err is a *provider.Error from this
|
|
// provider for op, in class and in no other class.
|
|
func assertProviderError(t *testing.T, err error, op string, class error) {
|
|
t.Helper()
|
|
if err == nil {
|
|
t.Fatalf("error = nil, want op %s class %v", op, class)
|
|
}
|
|
var pe *provider.Error
|
|
if !errors.As(err, &pe) {
|
|
t.Fatalf("error = %T (%v), want *provider.Error", err, err)
|
|
}
|
|
if pe.Provider != testProvider {
|
|
t.Errorf("error.Provider = %q, want %q", pe.Provider, testProvider)
|
|
}
|
|
if pe.Op != op {
|
|
t.Errorf("error.Op = %q, want %q", pe.Op, op)
|
|
}
|
|
if !errors.Is(err, class) {
|
|
t.Errorf("error = %v, want class %v", err, class)
|
|
}
|
|
for _, other := range []error{provider.ErrNotFound, provider.ErrQuotaExceeded, provider.ErrPermanent, provider.ErrTransient} {
|
|
if other != class && errors.Is(err, other) {
|
|
t.Errorf("error = %v, also matches %v", err, other)
|
|
}
|
|
}
|
|
}
|
|
|
|
// within runs fn and fails the test when it has not returned after d, so
|
|
// a call that ignores its ctx fails by name instead of hanging the package
|
|
// until the test binary's timeout.
|
|
func within(t *testing.T, d time.Duration, fn func()) {
|
|
t.Helper()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
fn()
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(d):
|
|
t.Fatalf("did not return within %v", d)
|
|
}
|
|
}
|
|
|
|
// cancelWhileBlocked runs call with a ctx it cancels once call has reached
|
|
// method, which blocks until then, and fails unless call returns within a
|
|
// second of the cancel. Every wait is bounded, so a call that never reaches
|
|
// method or never returns fails instead of hanging the package.
|
|
func cancelWhileBlocked(t *testing.T, fx *fixture, method string, call func(ctx context.Context)) {
|
|
t.Helper()
|
|
release := fx.fake.blockOn(method)
|
|
defer close(release)
|
|
ctx, cancel := context.WithCancel(fx.ctx)
|
|
defer cancel()
|
|
returned := make(chan struct{})
|
|
go func() {
|
|
defer close(returned)
|
|
call(ctx)
|
|
}()
|
|
deadline := time.After(5 * time.Second)
|
|
for fx.fake.callCount(method) == 0 {
|
|
select {
|
|
case <-returned:
|
|
t.Fatalf("returned before calling %s; calls: %v", method, fx.fake.callLog())
|
|
case <-deadline:
|
|
t.Fatalf("%s was not called within 5s; calls: %v", method, fx.fake.callLog())
|
|
case <-time.After(time.Millisecond):
|
|
}
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-returned:
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("did not return within 1s of the cancel while blocked in %s", method)
|
|
}
|
|
}
|
|
|
|
func TestProvider_cancelledContextReturnsPromptly(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
blockOn string
|
|
// first: blockOn is the operation's first seam call.
|
|
first bool
|
|
setup func(fx *fixture)
|
|
op string
|
|
call func(ctx context.Context, fx *fixture) error
|
|
}{
|
|
{
|
|
name: "create", blockOn: "ClusterResources", first: true, op: "create",
|
|
call: func(ctx context.Context, fx *fixture) error { _, err := fx.p.Create(ctx, fx.req()); return err },
|
|
},
|
|
{
|
|
name: "create mid upload", blockOn: "UploadISO", op: "create",
|
|
call: func(ctx context.Context, fx *fixture) error { _, err := fx.p.Create(ctx, fx.req()); return err },
|
|
},
|
|
{
|
|
name: "get", blockOn: "ClusterResources", first: true, op: "get",
|
|
setup: func(fx *fixture) { fx.addOurVM(testVMID, testName, "running") },
|
|
call: func(ctx context.Context, fx *fixture) error { _, err := fx.p.Get(ctx, fx.id()); return err },
|
|
},
|
|
{
|
|
name: "delete", blockOn: "ClusterResources", first: true, op: "delete",
|
|
setup: func(fx *fixture) { fx.addOurVM(testVMID, testName, "running") },
|
|
call: func(ctx context.Context, fx *fixture) error { return fx.p.Delete(ctx, fx.id()) },
|
|
},
|
|
{
|
|
name: "delete mid stop", blockOn: "Stop", op: "delete",
|
|
setup: func(fx *fixture) { fx.addOurVM(testVMID, testName, "running") },
|
|
call: func(ctx context.Context, fx *fixture) error { return fx.p.Delete(ctx, fx.id()) },
|
|
},
|
|
{
|
|
name: "list", blockOn: "ClusterResources", first: true, op: "list",
|
|
setup: func(fx *fixture) { fx.addOurVM(testVMID, testName, "running") },
|
|
call: func(ctx context.Context, fx *fixture) error { _, err := fx.p.ListByTag(ctx); return err },
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newCreateFixture(t, 1)
|
|
if tc.setup != nil {
|
|
tc.setup(fx)
|
|
}
|
|
var err error
|
|
cancelWhileBlocked(t, fx, tc.blockOn, func(ctx context.Context) { err = tc.call(ctx, fx) })
|
|
if calls := fx.fake.callLog(); tc.first && calls[0] != tc.blockOn {
|
|
t.Errorf("first call = %s, want %s", calls[0], tc.blockOn)
|
|
}
|
|
assertProviderError(t, err, tc.op, provider.ErrTransient)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("error = %v, want it to carry context.Canceled", err)
|
|
}
|
|
})
|
|
}
|
|
t.Run("check", func(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newCheckFixture(t)
|
|
var got []provider.TargetStatus
|
|
cancelWhileBlocked(t, fx, "Version", func(ctx context.Context) { got = fx.p.Check(ctx) })
|
|
if calls := fx.fake.callLog(); calls[0] != "Version" {
|
|
t.Errorf("first call = %s, want Version", calls[0])
|
|
}
|
|
if len(got) != 1 || got[0].Ready || got[0].Reason != provider.ReasonTimeout {
|
|
t.Errorf("Check() = %+v, want one target not ready with reason %s", got, provider.ReasonTimeout)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestProvider_everyErrorIsWrappedWithConfiguredName(t *testing.T) {
|
|
t.Parallel()
|
|
createWith := func(mutate func(*provider.CreateRequest)) func(context.Context, *fixture) error {
|
|
return func(ctx context.Context, fx *fixture) error {
|
|
req := fx.req()
|
|
if mutate != nil {
|
|
mutate(&req)
|
|
}
|
|
_, err := fx.p.Create(ctx, req)
|
|
return err
|
|
}
|
|
}
|
|
get := func(ctx context.Context, fx *fixture) error { _, err := fx.p.Get(ctx, fx.id()); return err }
|
|
del := func(ctx context.Context, fx *fixture) error { return fx.p.Delete(ctx, fx.id()) }
|
|
list := func(ctx context.Context, fx *fixture) error { _, err := fx.p.ListByTag(ctx); return err }
|
|
holdNameLock := func(fx *fixture, cancel context.CancelFunc) {
|
|
release, err := fx.p.nameLocks.Acquire(context.Background(), testName)
|
|
if err != nil {
|
|
fx.t.Fatalf("Acquire error = %v", err)
|
|
}
|
|
fx.t.Cleanup(release)
|
|
cancel()
|
|
}
|
|
broken := fakeErr(596, "Broken pipe")
|
|
tests := []struct {
|
|
name string
|
|
setup func(fx *fixture, cancel context.CancelFunc)
|
|
call func(ctx context.Context, fx *fixture) error
|
|
op string
|
|
id string
|
|
class error
|
|
}{
|
|
{name: "create without a name", call: createWith(func(r *provider.CreateRequest) { r.Name = "" }), op: "create", class: provider.ErrPermanent},
|
|
{name: "create unknown zone", call: createWith(func(r *provider.CreateRequest) { r.Placement.Zone = "pv9-9" }), op: "create", class: provider.ErrPermanent},
|
|
{name: "create unknown image", call: createWith(func(r *provider.CreateRequest) { r.Placement.Image = "arch" }), op: "create", class: provider.ErrPermanent},
|
|
{name: "create waiting on the name lock", setup: holdNameLock, call: createWith(nil), op: "create", class: provider.ErrTransient},
|
|
{
|
|
name: "create resources unreadable",
|
|
setup: func(fx *fixture, _ context.CancelFunc) { fx.fake.failWith("ClusterResources", broken) },
|
|
call: createWith(nil), op: "create", class: provider.ErrTransient,
|
|
},
|
|
{
|
|
name: "create clone forbidden",
|
|
setup: func(fx *fixture, _ context.CancelFunc) {
|
|
fx.fake.failWith("Clone", loadAPIError(fx.t, "err_forbidden"))
|
|
},
|
|
call: createWith(nil), op: "create", class: provider.ErrPermanent,
|
|
},
|
|
{
|
|
name: "create start task fails",
|
|
setup: func(fx *fixture, _ context.CancelFunc) { fx.fake.startFailsWith = "fake: start failed" },
|
|
call: createWith(nil), op: "create", class: provider.ErrTransient,
|
|
},
|
|
{
|
|
name: "get malformed id",
|
|
call: func(ctx context.Context, fx *fixture) error { _, err := fx.p.Get(ctx, "vms/abc/"+testName); return err },
|
|
op: "get", id: "vms/abc/" + testName, class: provider.ErrPermanent,
|
|
},
|
|
{name: "get missing vm", call: get, op: "get", id: "self", class: provider.ErrNotFound},
|
|
{
|
|
name: "get locked vm",
|
|
setup: func(fx *fixture, _ context.CancelFunc) { fx.addOurVM(testVMID, testName, "running").lock = "backup" },
|
|
call: get, op: "get", id: "self", class: provider.ErrTransient,
|
|
},
|
|
{
|
|
name: "get unauthorized",
|
|
setup: func(fx *fixture, _ context.CancelFunc) {
|
|
fx.fake.failWith("ClusterResources", loadAPIError(fx.t, "err_unauthorized"))
|
|
},
|
|
call: get, op: "get", id: "self", class: provider.ErrPermanent,
|
|
},
|
|
{
|
|
name: "delete malformed id",
|
|
call: func(ctx context.Context, fx *fixture) error { return fx.p.Delete(ctx, "vms/150042") },
|
|
op: "delete", id: "vms/150042", class: provider.ErrPermanent,
|
|
},
|
|
{name: "delete waiting on the name lock", setup: holdNameLock, call: del, op: "delete", id: "self", class: provider.ErrTransient},
|
|
{
|
|
name: "delete hibernated vm",
|
|
setup: func(fx *fixture, _ context.CancelFunc) {
|
|
fx.addOurVM(testVMID, testName, "stopped").lock = lockSuspended
|
|
},
|
|
call: del, op: "delete", id: "self", class: provider.ErrPermanent,
|
|
},
|
|
{
|
|
name: "delete destroy task fails",
|
|
setup: func(fx *fixture, _ context.CancelFunc) {
|
|
fx.addOurVM(testVMID, testName, "stopped")
|
|
fx.fake.destroyFailsWith = "fake: destroy failed"
|
|
},
|
|
call: del, op: "delete", id: "self", class: provider.ErrTransient,
|
|
},
|
|
{
|
|
name: "delete seed forbidden",
|
|
setup: func(fx *fixture, _ context.CancelFunc) {
|
|
fx.addOurVM(testVMID, testName, "stopped")
|
|
fx.fake.failWith("DeleteVolume", loadAPIError(fx.t, "err_forbidden_volume_delete"))
|
|
},
|
|
call: del, op: "delete", id: "self", class: provider.ErrPermanent,
|
|
},
|
|
{
|
|
name: "list resources unreadable",
|
|
setup: func(fx *fixture, _ context.CancelFunc) { fx.fake.failWith("ClusterResources", broken) },
|
|
call: list, op: "list", class: provider.ErrTransient,
|
|
},
|
|
{
|
|
name: "list unauthorized",
|
|
setup: func(fx *fixture, _ context.CancelFunc) {
|
|
fx.fake.failWith("ClusterResources", loadAPIError(fx.t, "err_unauthorized"))
|
|
},
|
|
call: list, op: "list", class: provider.ErrPermanent,
|
|
},
|
|
{
|
|
name: "list ended on a candidate",
|
|
setup: func(fx *fixture, cancel context.CancelFunc) {
|
|
fx.addOurVM(testVMID, testName, "running")
|
|
fx.fake.hookOn("VMConfig", func() error {
|
|
cancel()
|
|
return broken
|
|
})
|
|
},
|
|
call: list, op: "list", class: provider.ErrTransient,
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newCreateFixture(t, 1)
|
|
ctx, cancel := context.WithCancel(fx.ctx)
|
|
defer cancel()
|
|
if tc.setup != nil {
|
|
tc.setup(fx, cancel)
|
|
}
|
|
var err error
|
|
within(t, 5*time.Second, func() { err = tc.call(ctx, fx) })
|
|
assertProviderError(t, err, tc.op, tc.class)
|
|
wantID := tc.id
|
|
if wantID == "self" {
|
|
wantID = fx.id()
|
|
}
|
|
var pe *provider.Error
|
|
if errors.As(err, &pe) && pe.ID != wantID {
|
|
t.Errorf("error.ID = %q, want %q", pe.ID, wantID)
|
|
}
|
|
if !strings.HasPrefix(err.Error(), "provider "+testProvider+": "+tc.op) {
|
|
t.Errorf("error = %q, want it to start with the configured name and op", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// logSentinel is in the CloudInit the logging lifecycle creates with; no
|
|
// log line at any level may carry it.
|
|
const logSentinel = "SENTINEL-cloud-init-7e2a"
|
|
|
|
// newLoggingFixture is an observed create fixture with a token file, so
|
|
// one lifecycle covers every operation, Check and the decorator's lines.
|
|
func newLoggingFixture(t *testing.T, verbosity int) *fixture {
|
|
t.Helper()
|
|
fx := newCreateFixture(t, verbosity)
|
|
fx.p.api = observe(fx.fake, testProvider)
|
|
fx.p.cfg.TokenFile = filepath.Join(t.TempDir(), "token")
|
|
writeFile(t, fx.p.cfg.TokenFile, testToken+"\n")
|
|
return fx
|
|
}
|
|
|
|
// sentinelReq is fx.req() with logSentinel in its CloudInit.
|
|
func (fx *fixture) sentinelReq() provider.CreateRequest {
|
|
req := fx.req()
|
|
req.CloudInit = "#cloud-config\nwrite_files:\n - path: /etc/sentinel\n content: " + logSentinel + "\n"
|
|
return req
|
|
}
|
|
|
|
// lifecycle runs Create with sentinelReq, a Get that finds the address,
|
|
// ListByTag, Delete, Delete of the gone VM, and Check.
|
|
func (fx *fixture) lifecycle() {
|
|
fx.t.Helper()
|
|
id := fx.create(fx.sentinelReq())
|
|
ifaces := capturedIfaces(fx.t)
|
|
fx.fake.mu.Lock()
|
|
fx.fake.vms[slot(0)].ifaces = ifaces
|
|
fx.fake.mu.Unlock()
|
|
if inst := fx.get(id); inst.State != provider.StateRunning {
|
|
fx.t.Fatalf("Get state = %s, want Running", inst.State)
|
|
}
|
|
if _, err := fx.p.ListByTag(fx.ctx); err != nil {
|
|
fx.t.Fatalf("ListByTag error = %v", err)
|
|
}
|
|
for range 2 {
|
|
if err := fx.p.Delete(fx.ctx, id); err != nil {
|
|
fx.t.Fatalf("Delete error = %v", err)
|
|
}
|
|
}
|
|
if ts := fx.p.Check(fx.ctx); len(ts) != 1 || !ts[0].Ready {
|
|
fx.t.Fatalf("Check = %+v, want one Ready target", ts)
|
|
}
|
|
}
|
|
|
|
// assertNeverLogged checks every line for the cloud-init text and the
|
|
// token's secret.
|
|
func assertNeverLogged(t *testing.T, lines []string) {
|
|
t.Helper()
|
|
secret := testToken[strings.Index(testToken, "=")+1:]
|
|
for _, l := range lines {
|
|
for _, never := range []string{logSentinel, "#cloud-config", secret} {
|
|
if strings.Contains(l, never) {
|
|
t.Errorf("line carries %q: %s", never, l)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func msgLine(msg string) string { return `"msg"="` + msg + `"` }
|
|
|
|
// assertEveryV1Line checks that each line is from the proxmox logger at
|
|
// V(1) with the provider, and that each line about an instance, which is
|
|
// all but the decorator's, the list's and the check's, names its vmid and
|
|
// name.
|
|
func assertEveryV1Line(t *testing.T, lines []string, vmid, name string) {
|
|
t.Helper()
|
|
notInstance := []string{msgLine("proxmox api call"), msgLine("proxmox vms listed"), msgLine("proxmox node checked")}
|
|
for _, l := range lines {
|
|
if !strings.HasPrefix(l, "proxmox ") || !strings.Contains(l, `"provider"="`+testProvider+`"`) {
|
|
t.Errorf("line lacks the proxmox logger or the provider: %s", l)
|
|
}
|
|
if !strings.Contains(l, `"level"=1`) {
|
|
t.Errorf("line is not at V(1): %s", l)
|
|
}
|
|
instance := !slices.ContainsFunc(notInstance, func(m string) bool { return strings.Contains(l, m) })
|
|
if instance && (!strings.Contains(l, vmid) || !strings.Contains(l, name)) {
|
|
t.Errorf("instance line lacks vmid or name: %s", l)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLogging_v0SilentV1OutcomeV2Detail(t *testing.T) {
|
|
t.Parallel()
|
|
vmid := `"vmid"=` + strconv.Itoa(slot(0))
|
|
name := `"name"="` + testName + `"`
|
|
t.Run("v0 is silent", func(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newLoggingFixture(t, 0)
|
|
fx.lifecycle()
|
|
if lines := fx.logs.all(); len(lines) != 0 {
|
|
t.Errorf("the lifecycle logged %d lines at V(0), want none:\n%s", len(lines), strings.Join(lines, "\n"))
|
|
}
|
|
})
|
|
t.Run("v1 has one outcome per operation", func(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newLoggingFixture(t, 1)
|
|
fx.lifecycle()
|
|
all := fx.logs.all()
|
|
dump := strings.Join(all, "\n")
|
|
outcomes := map[string][]string{
|
|
"proxmox vm ensured running": {vmid, name, `"node"="pv1-1"`},
|
|
"proxmox vm fetched": {vmid, name, `"state"="Running"`, `"ip"="192.168.0.150"`, `"uid"="` + testUID + `"`},
|
|
"proxmox vms listed": {`"candidates"=1`, `"managed"=1`, `"skipped"=0`},
|
|
"proxmox node checked": {`"node"="pv1-1"`, `"ready"=true`, `"reason"="Reachable"`},
|
|
}
|
|
for msg, keys := range outcomes {
|
|
lines := fx.logs.matching(msgLine(msg))
|
|
if len(lines) != 1 {
|
|
t.Errorf("lines %q = %d, want 1:\n%s", msg, len(lines), dump)
|
|
continue
|
|
}
|
|
for _, k := range keys {
|
|
if !strings.Contains(lines[0], k) {
|
|
t.Errorf("%q line lacks %s: %s", msg, k, lines[0])
|
|
}
|
|
}
|
|
}
|
|
deleted := fx.logs.matching(msgLine("proxmox vm deleted"))
|
|
if len(deleted) != 2 {
|
|
t.Fatalf("deleted lines = %d, want 2 (destroyed, then already gone):\n%s", len(deleted), dump)
|
|
}
|
|
if l := deleted[0]; !strings.Contains(l, `"destroyed"=true`) || !strings.Contains(l, `"node"="pv1-1"`) || !strings.Contains(l, vmid) || !strings.Contains(l, name) {
|
|
t.Errorf("first deleted line = %s, want destroyed=true with node, vmid and name", l)
|
|
}
|
|
if l := deleted[1]; !strings.Contains(l, `"destroyed"=false`) || strings.Contains(l, `"node"=`) || !strings.Contains(l, vmid) || !strings.Contains(l, name) {
|
|
t.Errorf("second deleted line = %s, want destroyed=false with vmid and name and no node", l)
|
|
}
|
|
assertEveryV1Line(t, all, vmid, name)
|
|
for _, v2 := range []string{"cloudInitBytes", "proxmox create resolved", "proxmox guest agent answered", "proxmox listed instance", `"description"=`, `"config"=`, `"params"=`} {
|
|
if got := fx.logs.matching(v2); len(got) != 0 {
|
|
t.Errorf("V(1) carries the V(2) detail %s:\n%s", v2, strings.Join(got, "\n"))
|
|
}
|
|
}
|
|
assertNeverLogged(t, all)
|
|
})
|
|
t.Run("v2 has detail and never the cloud-init", func(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newLoggingFixture(t, 2)
|
|
fx.lifecycle()
|
|
resolved := fx.logs.matching(msgLine("proxmox create resolved"))
|
|
if len(resolved) != 1 {
|
|
t.Fatalf("resolved lines = %d, want 1", len(resolved))
|
|
}
|
|
for _, k := range []string{name, `"cloudInitBytes"=` + strconv.Itoa(len(fx.sentinelReq().CloudInit)), `"machineType"="small"`, `"cores"=1`, `"memoryMiB"=1024`, `"templateVMID"=9001`, `"isoStorage"="proxy-seeds"`} {
|
|
if !strings.Contains(resolved[0], k) {
|
|
t.Errorf("resolved line lacks %s: %s", k, resolved[0])
|
|
}
|
|
}
|
|
details := map[string]string{
|
|
msgLine("proxmox guest agent answered"): `"ip"="192.168.0.150"`,
|
|
msgLine("proxmox listed instance"): `"uid"="` + testUID + `"`,
|
|
`"call"="Clone"`: `"description"="proxy-operator-managed=true`,
|
|
`"call"="VMConfig"`: `"config"=`,
|
|
`"call"="UploadISO"`: `"bytes"=`,
|
|
}
|
|
for line, k := range details {
|
|
got := fx.logs.matching(line)
|
|
if len(got) == 0 || !strings.Contains(got[0], k) {
|
|
t.Errorf("V(2) %s line lacks %s: %v", line, k, got)
|
|
}
|
|
}
|
|
assertNeverLogged(t, fx.logs.all())
|
|
})
|
|
}
|
|
|
|
// TestLogging_noOpLinesAtV1: spec entry 20's no-op lines of Delete and
|
|
// ListByTag are there at V(1), each naming the instance.
|
|
func TestLogging_noOpLinesAtV1(t *testing.T) {
|
|
t.Parallel()
|
|
del := func(fx *fixture) error { return fx.p.Delete(fx.ctx, fx.id()) }
|
|
tests := []struct {
|
|
name string
|
|
setup func(fx *fixture)
|
|
call func(fx *fixture) error
|
|
msg string
|
|
want int
|
|
}{
|
|
{name: "vm already gone", call: del, msg: "proxmox vm already gone", want: 1},
|
|
{
|
|
name: "foreign vm",
|
|
setup: func(fx *fixture) { fx.addOurVM(testVMID, "proxy-someoneelse00000", "running") },
|
|
call: del, msg: "proxmox vm at this vmid is not ours, left alone", want: 1,
|
|
},
|
|
{
|
|
name: "unmarked vm",
|
|
setup: func(fx *fixture) { fx.fake.addVM(testVMID, testName, "running") },
|
|
call: del, msg: "proxmox vm carries no proxy-operator mark, left alone", want: 1,
|
|
},
|
|
{
|
|
name: "seed delete skipped on each node",
|
|
setup: func(fx *fixture) {
|
|
fx.p.cfg.Nodes = []string{fakeNode, "pv1-2"}
|
|
fx.fake.failWith("DeleteVolume", loadAPIError(fx.t, "err_forbidden_volume_delete"))
|
|
},
|
|
call: del, msg: "proxmox seed delete failed on a node, skipped", want: 2,
|
|
},
|
|
{
|
|
name: "unreadable candidate",
|
|
setup: func(fx *fixture) {
|
|
fx.addOurVM(testVMID, testName, "running")
|
|
fx.fake.failWith("VMConfig", fakeErr(596, "Broken pipe"))
|
|
},
|
|
call: func(fx *fixture) error { _, err := fx.p.ListByTag(fx.ctx); return err },
|
|
msg: "skipping unreadable proxmox vm", want: 1,
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
fx := newFixture(t, 1)
|
|
if tc.setup != nil {
|
|
tc.setup(fx)
|
|
}
|
|
if err := tc.call(fx); err != nil {
|
|
t.Fatalf("error = %v, want nil", err)
|
|
}
|
|
lines := fx.logs.matching(msgLine(tc.msg))
|
|
if len(lines) != tc.want {
|
|
t.Fatalf("lines %q = %d, want %d:\n%s", tc.msg, len(lines), tc.want, strings.Join(fx.logs.all(), "\n"))
|
|
}
|
|
for _, l := range lines {
|
|
for _, k := range []string{`"provider"="` + testProvider + `"`, `"vmid"=150042`, `"level"=1`} {
|
|
if !strings.Contains(l, k) {
|
|
t.Errorf("line lacks %s: %s", k, l)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTracing_httpSpansUnderOperationSpan runs a Get through
|
|
// provider.WithTracing over the real client against Phase 0's captures:
|
|
// each of Get's four requests is an HTTP span under provider.get, and no
|
|
// span attribute carries the token.
|
|
func TestTracing_httpSpansUnderOperationSpan(t *testing.T) {
|
|
t.Parallel()
|
|
const (
|
|
vmid = 199999
|
|
name = "proxy-reconaaaaaaaaaaa"
|
|
)
|
|
cfx := newClientFixture(t, func(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/api2/json/")
|
|
capture := map[string]string{
|
|
"cluster/resources": "cluster_resources_during_destroy",
|
|
"nodes/pv1-1/qemu/199999/config": "config_during_destroy",
|
|
"nodes/pv1-1/qemu/199999/status/current": "status_current_running",
|
|
"nodes/pv1-1/qemu/199999/agent/network-get-interfaces": "agent_network_get_interfaces",
|
|
}[path]
|
|
if r.Method != http.MethodGet || capture == "" {
|
|
t.Errorf("unexpected request %s %s", r.Method, path)
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
serveJSON(w, readCapture(t, capture).Body)
|
|
})
|
|
sr := tracetest.NewSpanRecorder()
|
|
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr))
|
|
t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
|
|
opt := tracing.WithTracerProvider(tp)
|
|
|
|
cfg := testConfig()
|
|
cfg.URL, cfg.TokenFile, cfg.CAFile = cfx.cfg.URL, cfx.cfg.TokenFile, cfx.cfg.CAFile
|
|
p := newProvider(testProvider, cfg, observe(cfx.api(t, opt), testProvider))
|
|
p.budgets = testBudgets()
|
|
traced := provider.WithTracing(testProvider, p, opt)
|
|
|
|
inst, err := traced.Get(context.Background(), formatProviderID(vmid, name))
|
|
if err != nil {
|
|
t.Fatalf("Get error = %v", err)
|
|
}
|
|
if inst.State != provider.StateRunning || inst.IP != "192.168.0.150" {
|
|
t.Errorf("Get = %+v, want Running at 192.168.0.150", inst)
|
|
}
|
|
|
|
var (
|
|
op sdktrace.ReadOnlySpan
|
|
requests []sdktrace.ReadOnlySpan
|
|
)
|
|
for _, s := range sr.Ended() {
|
|
if s.Name() == "provider.get" {
|
|
op = s
|
|
} else {
|
|
requests = append(requests, s)
|
|
}
|
|
}
|
|
if op == nil {
|
|
t.Fatalf("no provider.get span among %d", len(sr.Ended()))
|
|
}
|
|
if len(requests) != 4 {
|
|
t.Errorf("HTTP spans = %d, want one per request of Get (4)", len(requests))
|
|
}
|
|
for _, s := range requests {
|
|
if s.Name() != "HTTP GET" {
|
|
t.Errorf("span %q, want HTTP GET (spec entry 17)", s.Name())
|
|
}
|
|
if s.Parent().SpanID() != op.SpanContext().SpanID() || s.SpanContext().TraceID() != op.SpanContext().TraceID() {
|
|
t.Errorf("span %q is not a child of provider.get", s.Name())
|
|
}
|
|
}
|
|
secret := testToken[strings.Index(testToken, "=")+1:]
|
|
for _, s := range sr.Ended() {
|
|
for _, kv := range s.Attributes() {
|
|
if v := kv.Value.Emit(); strings.Contains(v, secret) || strings.Contains(v, "PVEAPIToken") {
|
|
t.Errorf("span %s attribute %s carries the token: %s", s.Name(), kv.Key, v)
|
|
}
|
|
}
|
|
}
|
|
}
|