Files
egress-proxies-operator/internal/provider/proxmox/observe_test.go
T
kacerrandClaude Opus 5.5 e05d994974 proxmox: thin HTTP client, seam, log decorator and in-memory fake
httpAPI implements the sixteen seam methods over net/http: the token is
read per request, non-2xx answers become apiError from the body message or
the reason phrase, echoed secrets are redacted, the CA loads lazily, HTTP/2
is off, and uploads stream with an exact Content-Length. The observed
decorator logs one V(1) line per call and starts no span; New wires both.
fakeAPI simulates clone locks, tasks and ISOs with the captured error texts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 14:47:50 +00:00

230 lines
7.9 KiB
Go

package proxmox
import (
"context"
"errors"
"reflect"
"strconv"
"strings"
"sync"
"testing"
"github.com/go-logr/logr/funcr"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
logf "sigs.k8s.io/controller-runtime/pkg/log"
)
// logCapture collects funcr output, safe for concurrent use.
type logCapture struct {
mu sync.Mutex
lines []string
}
// context returns a ctx whose logger writes into c at verbosity.
func (c *logCapture) context(verbosity int) context.Context {
log := funcr.New(func(prefix, args string) {
c.mu.Lock()
defer c.mu.Unlock()
c.lines = append(c.lines, prefix+" "+args)
}, funcr.Options{Verbosity: verbosity})
return logf.IntoContext(context.Background(), log)
}
func (c *logCapture) all() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.lines...)
}
func (c *logCapture) matching(substr string) []string {
var out []string
for _, l := range c.all() {
if strings.Contains(l, substr) {
out = append(out, l)
}
}
return out
}
const callLine = `"msg"="proxmox api call"`
func newObserveFixture(verbosity int) (*fakeAPI, api, *logCapture, context.Context) {
fake := newFakeAPI()
logs := &logCapture{}
return fake, observe(fake, "proxmox-home"), logs, logs.context(verbosity)
}
func TestObserve_everySeamMethodLogsOneLine(t *testing.T) {
t.Parallel()
for m := range reflect.TypeFor[api]().Methods() {
t.Run(m.Name, func(t *testing.T) {
t.Parallel()
fake, a, logs, ctx := newObserveFixture(1)
args := make([]reflect.Value, m.Type.NumIn())
args[0] = reflect.ValueOf(ctx)
for j := 1; j < m.Type.NumIn(); j++ {
args[j] = reflect.Zero(m.Type.In(j))
}
reflect.ValueOf(a).MethodByName(m.Name).Call(args)
lines := logs.matching(callLine)
if len(lines) != 1 || len(logs.all()) != 1 {
t.Fatalf("got %d call lines of %d, want exactly 1:\n%s", len(lines), len(logs.all()), strings.Join(logs.all(), "\n"))
}
if !strings.HasPrefix(lines[0], "proxmox ") {
t.Errorf("line is not from the proxmox logger: %s", lines[0])
}
for _, want := range []string{`"call"="` + m.Name + `"`, `"provider"="proxmox-home"`, `"duration"=`, `"level"=1`} {
if !strings.Contains(lines[0], want) {
t.Errorf("line lacks %s: %s", want, lines[0])
}
}
if n := fake.callCount(m.Name); n != 1 {
t.Errorf("inner %s called %d times, want 1", m.Name, n)
}
})
}
}
// TestObserve_startsNoSpan keeps the decorator from duplicating the
// transport's HTTP spans (spec entry 17).
func TestObserve_startsNoSpan(t *testing.T) {
t.Parallel()
sr := tracetest.NewSpanRecorder()
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr))
t.Cleanup(func() { _ = tp.Shutdown(context.Background()) })
fake, a, _, ctx := newObserveFixture(1)
fake.addVM(150042, "proxy-x", "running")
ctx, parent := tp.Tracer("test").Start(ctx, "provider.get")
_, _ = a.ClusterResources(ctx)
_, _ = a.VMConfig(ctx, fakeNode, 150042)
parent.End()
if ended := sr.Ended(); len(ended) != 1 || ended[0].Name() != "provider.get" {
t.Errorf("spans = %d, want only the parent", len(ended))
}
}
func TestObserve_identifiersAtV1(t *testing.T) {
t.Parallel()
fake, a, logs, ctx := newObserveFixture(1)
fake.addTemplate(9001, "debian-13-template")
upid, err := a.Clone(ctx, fakeNode, 9001, cloneParams{NewID: 150042, Name: "proxy-x", Description: "proxy-operator-managed=true"})
if err != nil {
t.Fatalf("Clone error = %v", err)
}
_, _ = a.TaskStatus(ctx, fakeNode, upid)
_, _ = a.DeleteVolume(ctx, fakeNode, fakeISOStorage, seedVolID(fakeISOStorage, "proxy-x"))
lines := logs.matching(callLine)
if len(lines) != 3 {
t.Fatalf("got %d lines, want 3:\n%s", len(lines), strings.Join(logs.all(), "\n"))
}
wants := [][]string{
{`"call"="Clone" "node"="pv1-1" "vmid"=9001 "upid"="` + upid + `" "newid"=150042 "name"="proxy-x"`},
{`"call"="TaskStatus" "node"="pv1-1" "upid"="` + upid + `"`},
{`"call"="DeleteVolume" "node"="pv1-1" "storage"="proxy-seeds"`, `"volid"="proxy-seeds:iso/proxy-x-seed.iso"`, `"upid"="UPID:pv1-1:`},
}
for i, want := range wants {
for _, w := range want {
if !strings.Contains(lines[i], w) {
t.Errorf("line %d lacks %s:\n%s", i, w, lines[i])
}
}
}
if strings.Contains(lines[0], "description") {
t.Errorf("the description reached V(1): %s", lines[0])
}
}
func TestObserve_detailOnlyAtV2(t *testing.T) {
t.Parallel()
tests := []struct {
name string
verbosity int
want bool
}{
{name: "V1 has no detail", verbosity: 1, want: false},
{name: "V2 has detail", verbosity: 2, want: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fake, a, logs, ctx := newObserveFixture(tc.verbosity)
fake.addTemplate(9001, "debian-13-template")
vm := fake.addVM(150042, "proxy-x", "running")
vm.ifaces = []agentInterface{{Name: "ens18", IPAddresses: []agentIP{{Address: "192.168.4.147", Type: "ipv4"}}}}
_, _ = a.ClusterResources(ctx)
_, _ = a.VMConfig(ctx, fakeNode, 150042)
_ = a.SetVMConfig(ctx, fakeNode, 9001, map[string]string{"cores": "3"})
_, _ = a.Clone(ctx, fakeNode, 9001, cloneParams{NewID: 150043, Name: "proxy-y", Description: "mark-line"})
_, _ = a.AgentNetworkInterfaces(ctx, fakeNode, 150042)
all := strings.Join(logs.all(), "\n")
for _, detail := range []string{`"vms"=["9001:debian-13-template:stopped" "150042:proxy-x:running"]`, `"config"={`, `"params"={"cores"="3"}`, `"description"="mark-line"`, `"addresses"=["ens18=192.168.4.147"]`} {
if got := strings.Contains(all, detail); got != tc.want {
t.Errorf("%s logged = %v, want %v:\n%s", detail, got, tc.want, all)
}
}
if got := len(logs.matching(callLine)); got != 5 {
t.Errorf("got %d call lines, want 5", got)
}
})
}
}
func TestObserve_uploadBodyNeverLogged(t *testing.T) {
t.Parallel()
const sentinel = "SENTINEL-CLOUDINIT-51c9"
fake, a, logs, ctx := newObserveFixture(2)
body := "#cloud-config\npassword: " + sentinel + "\n"
if _, err := a.UploadISO(ctx, fakeNode, fakeISOStorage, "proxy-x-seed.iso", strings.NewReader(body), int64(len(body))); err != nil {
t.Fatalf("UploadISO error = %v", err)
}
if got, _ := fake.iso(fakeISOStorage, "proxy-x-seed.iso"); string(got) != body {
t.Errorf("fake got %q, want the body passed through", got)
}
all := strings.Join(logs.all(), "\n")
if strings.Contains(all, sentinel) {
t.Errorf("upload body leaked into logs:\n%s", all)
}
lines := logs.matching(`"call"="UploadISO"`)
if len(lines) != 1 || !strings.Contains(lines[0], `"filename"="proxy-x-seed.iso"`) || !strings.Contains(lines[0], `"bytes"=`+strconv.Itoa(len(body))) {
t.Errorf("upload line lacks filename or bytes: %v", lines)
}
}
func TestObserve_errorFieldsOnFailure(t *testing.T) {
t.Parallel()
fake, a, logs, ctx := newObserveFixture(1)
_, err := a.VMConfig(ctx, fakeNode, 999999)
if !isNotExist(err) {
t.Fatalf("error = %v, want the does-not-exist apiError through the decorator", err)
}
lines := logs.matching(callLine)
if len(lines) != 1 {
t.Fatalf("got %d lines, want 1", len(lines))
}
for _, want := range []string{`"httpStatus"=500`, `"error"="proxmox API 500: Configuration file 'nodes/pv1-1/qemu-server/999999.conf' does not exist"`} {
if !strings.Contains(lines[0], want) {
t.Errorf("line lacks %s: %s", want, lines[0])
}
}
fake.failWith("Version", errors.New("dial tcp: connection refused"))
_, _ = a.Version(ctx)
lines = logs.matching(`"call"="Version"`)
if len(lines) != 1 || strings.Contains(lines[0], "httpStatus") || !strings.Contains(lines[0], `"error"="dial tcp: connection refused"`) {
t.Errorf("non-API failure line = %v, want the error without httpStatus", lines)
}
}
func TestObserve_v0Silent(t *testing.T) {
t.Parallel()
fake, a, logs, ctx := newObserveFixture(0)
fake.addVM(150042, "proxy-x", "running")
_, _ = a.VMConfig(ctx, fakeNode, 150042)
_, _ = a.VMConfig(ctx, fakeNode, 999999)
if lines := logs.all(); len(lines) != 0 {
t.Errorf("got %d lines at V(0), want none:\n%s", len(lines), strings.Join(lines, "\n"))
}
}