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")) } }