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>
230 lines
7.9 KiB
Go
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"))
|
|
}
|
|
}
|