Skip to content

Commit 376f6d6

Browse files
ChrisJBurnsclaude
andcommitted
Address review and lint on drift detection walker
Fix lint failures from CI: rename reflect.Ptr to reflect.Pointer, suppress exhaustive on the kind switch with rationale, and tag the ,inline json fixtures as a known revive false positive. Replace the explicit leafTypes allowlist with a json.Marshaler interface check. Every entry in the previous map shared one property — a custom MarshalJSON whose output bears no relation to the Go field layout — so asking the type how it serializes is more general than maintaining a hand-rolled list of K8s and project types. The walker is now self-maintaining for any future custom-marshaled type. Address reviewer findings on telemetry_drift_test.go: - delete dead sortedKeys helper and its misleading comment - upgrade per-entry empty-field checks to require to avoid empty-string pollution of the duplicate-detection maps - assert no path appears in both ignore maps (cross-pollination) - assert every mapping/ignore entry is still a live leaf on its type so renames and deletions surface instead of leaving stale entries - rewrite caCertPath, sensitiveHeaders, and serviceName justifications to name the actual wiring symbols a reviewer can grep for Tighten the FlattenJSONLeafFields godoc to describe the one-level self-reference expansion that maxStructRevisits actually permits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ffcfd8e commit 376f6d6

3 files changed

Lines changed: 103 additions & 53 deletions

File tree

cmd/thv-operator/internal/testutil/reflect.go

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,8 @@ import (
1010
"reflect"
1111
"sort"
1212
"strings"
13-
"time"
1413

1514
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16-
17-
thvjson "github.com/stacklok/toolhive/pkg/json"
18-
vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config"
1915
)
2016

2117
// FlattenJSONLeafFields returns every leaf JSON field path under t as a sorted
@@ -35,20 +31,29 @@ import (
3531
// `tools` slice has a `workload` field".
3632
// - Map types whose VALUE type is a struct (or pointer to a struct) recurse
3733
// into that value type the same way.
38-
// - All other types (primitives, []string, map[string]string,
39-
// time.Duration, metav1.Duration, json.RawMessage, and any type listed
40-
// in the leaf allowlist below) terminate the walk and contribute one
41-
// leaf path entry.
42-
// - To prevent infinite recursion on self-referential types, track visited
43-
// types per walk and stop if a cycle is detected.
34+
// - Types that implement json.Marshaler are treated as leaves regardless of
35+
// their underlying kind. A custom MarshalJSON produces a JSON shape that
36+
// bears no relation to the Go field layout, so walking the fields would
37+
// emit paths that never appear in the serialized form. This handles
38+
// metav1.Time, metav1.Duration, intstr.IntOrString, resource.Quantity,
39+
// json.RawMessage, and any project-local custom-marshaled types
40+
// (e.g. pkg/json.Map, pkg/json.Any, pkg/vmcp/config.Duration) without an
41+
// explicit allow-list.
42+
// - All other primitive types (primitives, []string, map[string]string,
43+
// time.Duration which is just int64) terminate the walk and contribute
44+
// one leaf path entry via the default branch.
45+
// - Self-referential types are expanded one level (the original visit plus
46+
// one self-reference) and then truncated, to keep the walk terminating.
47+
// Subsequent levels (e.g. "next.next.<field>") are intentionally elided.
48+
// See maxStructRevisits for the controlling constant.
4449
//
4550
// If t is nil or, after dereferencing, is not a struct, an empty slice is
4651
// returned rather than panicking.
4752
func FlattenJSONLeafFields(t reflect.Type) []string {
4853
if t == nil {
4954
return []string{}
5055
}
51-
for t.Kind() == reflect.Ptr {
56+
for t.Kind() == reflect.Pointer {
5257
t = t.Elem()
5358
}
5459
if t.Kind() != reflect.Struct {
@@ -67,15 +72,16 @@ func FlattenJSONLeafFields(t reflect.Type) []string {
6772
return out
6873
}
6974

70-
// leafTypes lists types that terminate the walk even though they are structs.
71-
// They contribute a single leaf entry at their parent path.
72-
var leafTypes = map[reflect.Type]struct{}{
73-
reflect.TypeOf(time.Duration(0)): {},
74-
reflect.TypeOf(metav1.Duration{}): {},
75-
reflect.TypeOf(json.RawMessage{}): {},
76-
reflect.TypeOf(thvjson.Map{}): {},
77-
reflect.TypeOf(thvjson.Any{}): {},
78-
reflect.TypeOf(vmcpconfig.Duration(0)): {},
75+
// jsonMarshalerType is the reflect.Type of the json.Marshaler interface. Any
76+
// type implementing it (directly or via pointer receiver) produces a JSON
77+
// shape detached from its Go field layout, so it must terminate the walk.
78+
var jsonMarshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem()
79+
80+
// implementsJSONMarshaler reports whether values of t (or *t) have a custom
81+
// MarshalJSON method. The pointer-receiver check matters because Go method
82+
// sets only include pointer-receiver methods when the receiver is addressable.
83+
func implementsJSONMarshaler(t reflect.Type) bool {
84+
return t.Implements(jsonMarshalerType) || reflect.PointerTo(t).Implements(jsonMarshalerType)
7985
}
8086

8187
// skipFieldTypes lists embedded struct types that must be skipped entirely.
@@ -136,36 +142,40 @@ func walkStruct(t reflect.Type, prefix string, leafSet map[string]struct{}, visi
136142
// recurses into nested structs/slices/maps or records a leaf at prefix.
137143
func walkType(t reflect.Type, prefix string, leafSet map[string]struct{}, visited map[reflect.Type]int) {
138144
// Deref pointers.
139-
for t.Kind() == reflect.Ptr {
145+
for t.Kind() == reflect.Pointer {
140146
t = t.Elem()
141147
}
142148

143-
// Allow-listed leaf types short-circuit any further walking.
144-
if _, ok := leafTypes[t]; ok {
149+
// Custom JSON marshalers short-circuit any further walking. See
150+
// jsonMarshalerType for the rationale.
151+
if implementsJSONMarshaler(t) {
145152
recordLeaf(prefix, leafSet)
146153
return
147154
}
148155

149-
switch t.Kind() {
156+
// Only Struct/Slice/Array/Map require recursion; every other Kind
157+
// (primitives, interfaces, channels, etc.) is a leaf path captured by
158+
// the default branch.
159+
switch t.Kind() { //exhaustive:ignore
150160
case reflect.Struct:
151161
recurseStruct(t, prefix, leafSet, visited)
152162
case reflect.Slice, reflect.Array:
153163
elem := t.Elem()
154-
for elem.Kind() == reflect.Ptr {
164+
for elem.Kind() == reflect.Pointer {
155165
elem = elem.Elem()
156166
}
157-
// Recurse only when the element is a struct that is NOT a leaf type.
158-
if _, isLeaf := leafTypes[elem]; !isLeaf && elem.Kind() == reflect.Struct {
167+
// Recurse only when the element is a plain struct (no custom marshaler).
168+
if elem.Kind() == reflect.Struct && !implementsJSONMarshaler(elem) {
159169
recurseStruct(elem, prefix, leafSet, visited)
160170
return
161171
}
162172
recordLeaf(prefix, leafSet)
163173
case reflect.Map:
164174
val := t.Elem()
165-
for val.Kind() == reflect.Ptr {
175+
for val.Kind() == reflect.Pointer {
166176
val = val.Elem()
167177
}
168-
if _, isLeaf := leafTypes[val]; !isLeaf && val.Kind() == reflect.Struct {
178+
if val.Kind() == reflect.Struct && !implementsJSONMarshaler(val) {
169179
recurseStruct(val, prefix, leafSet, visited)
170180
return
171181
}

cmd/thv-operator/internal/testutil/reflect_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ type withEmbeddedNoInline struct {
8181
}
8282

8383
type withEmbeddedInline struct {
84-
embedSource `json:",inline"`
85-
Bar string `json:"bar"`
84+
embedSource `json:",inline"` //nolint:revive // inline is a valid kubernetes json tag option
85+
Bar string `json:"bar"`
8686
}
8787

8888
type withUnexportedField struct {
@@ -266,7 +266,7 @@ func TestFlattenJSONLeafFields_SkipsObjectMetaAndTypeMeta(t *testing.T) {
266266
t.Parallel()
267267

268268
type withK8sMeta struct {
269-
metav1.TypeMeta `json:",inline"`
269+
metav1.TypeMeta `json:",inline"` //nolint:revive // inline is a valid kubernetes json tag option
270270
metav1.ObjectMeta `json:"metadata,omitempty"`
271271
Spec flatPrimitives `json:"spec"`
272272
}

cmd/thv-operator/pkg/spectoconfig/telemetry_drift_test.go

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ package spectoconfig
2525

2626
import (
2727
"reflect"
28-
"sort"
2928
"testing"
3029

3130
"github.com/stretchr/testify/assert"
31+
"github.com/stretchr/testify/require"
3232

3333
"github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
3434
"github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil"
@@ -61,22 +61,27 @@ var telemetryFieldMappings = []FieldMapping{
6161
// telemetryIgnoredOnCRDOnly lists CRD leaf fields that intentionally have no
6262
// runtime counterpart. Each entry MUST include a justification.
6363
var telemetryIgnoredOnCRDOnly = map[string]string{
64-
"openTelemetry.enabled": "CRD-only gate; controls whether the converter populates runtime fields at all",
65-
"openTelemetry.sensitiveHeaders.name": "K8s-secret-backed headers; resolved by operator into runtime Headers",
66-
"openTelemetry.sensitiveHeaders.secretKeyRef.name": "K8s-secret-backed headers; resolved by operator into runtime Headers",
67-
"openTelemetry.sensitiveHeaders.secretKeyRef.key": "K8s-secret-backed headers; resolved by operator into runtime Headers",
68-
"openTelemetry.caBundleRef.configMapRef.name": "K8s ConfigMap reference; resolved by operator into runtime CACertPath",
69-
"openTelemetry.caBundleRef.configMapRef.key": "K8s ConfigMap reference; resolved by operator into runtime CACertPath",
70-
"openTelemetry.caBundleRef.configMapRef.optional": "K8s ConfigMap reference flag promoted from corev1.ConfigMapKeySelector; not part of runtime config",
64+
"openTelemetry.enabled": "CRD-only gate; controls whether the converter populates runtime fields at all",
65+
"openTelemetry.sensitiveHeaders.name": "K8s-secret-backed header value; injected as TOOLHIVE_OTEL_HEADER_* env vars on the proxyrunner pod " +
66+
"(controllerutil.GenerateOpenTelemetryEnvVarsFromRef) and merged into OTLP headers at runtime; not written into telemetry.Config.Headers by the converter",
67+
"openTelemetry.sensitiveHeaders.secretKeyRef.name": "K8s-secret-backed header value; injected as TOOLHIVE_OTEL_HEADER_* env vars on the proxyrunner pod " +
68+
"(controllerutil.GenerateOpenTelemetryEnvVarsFromRef) and merged into OTLP headers at runtime; not written into telemetry.Config.Headers by the converter",
69+
"openTelemetry.sensitiveHeaders.secretKeyRef.key": "K8s-secret-backed header value; injected as TOOLHIVE_OTEL_HEADER_* env vars on the proxyrunner pod " +
70+
"(controllerutil.GenerateOpenTelemetryEnvVarsFromRef) and merged into OTLP headers at runtime; not written into telemetry.Config.Headers by the converter",
71+
"openTelemetry.caBundleRef.configMapRef.name": "K8s ConfigMap reference; resolved by operator into runtime CACertPath",
72+
"openTelemetry.caBundleRef.configMapRef.key": "K8s ConfigMap reference; resolved by operator into runtime CACertPath",
73+
"openTelemetry.caBundleRef.configMapRef.optional": "K8s ConfigMap reference flag promoted from corev1.ConfigMapKeySelector; not part of runtime config",
7174
}
7275

7376
// telemetryIgnoredOnRuntimeOnly lists runtime leaf fields that intentionally
7477
// have no CRD counterpart. Each entry MUST include a justification.
7578
var telemetryIgnoredOnRuntimeOnly = map[string]string{
76-
"serviceName": "per-server, set via MCPTelemetryConfigReference.ServiceName, not stored on the shared MCPTelemetryConfig",
79+
"serviceName": "per-server, set from MCPTelemetryConfigReference.ServiceName and defaulted at runtime by " +
80+
"telemetry.ResolveServiceName; intentionally absent from the shared MCPTelemetryConfig",
7781
"serviceVersion": "resolved at runtime from binary version (issue #2296)",
7882
"environmentVariables": "CLI-only, not applicable to CRD-managed telemetry",
79-
"caCertPath": "filesystem path computed by the operator from caBundleRef; not user-facing in the CRD",
83+
"caCertPath": "filesystem path injected by runconfig.AppendTelemetryRunnerOption after the operator computes the volume mount " +
84+
"path from openTelemetry.caBundleRef; not user-facing in the CRD",
8085
}
8186

8287
// TestTelemetryConfigDrift_CRDFieldsCovered walks MCPTelemetryConfigSpec and
@@ -144,9 +149,12 @@ func TestTelemetryConfigDrift_MappingTableSanity(t *testing.T) {
144149
seenCRD := make(map[string]int, len(telemetryFieldMappings))
145150
seenRuntime := make(map[string]int, len(telemetryFieldMappings))
146151

152+
// Use require for the per-entry NotEmpty checks so that an empty CRD
153+
// or Runtime field doesn't pollute the duplicate maps below with an
154+
// empty-string key — that would trigger a misleading cascade.
147155
for i, m := range telemetryFieldMappings {
148-
assert.NotEmptyf(t, m.CRD, "telemetryFieldMappings[%d].CRD must not be empty", i)
149-
assert.NotEmptyf(t, m.Runtime, "telemetryFieldMappings[%d].Runtime must not be empty", i)
156+
require.NotEmptyf(t, m.CRD, "telemetryFieldMappings[%d].CRD must not be empty", i)
157+
require.NotEmptyf(t, m.Runtime, "telemetryFieldMappings[%d].Runtime must not be empty", i)
150158
seenCRD[m.CRD]++
151159
seenRuntime[m.Runtime]++
152160
}
@@ -176,17 +184,49 @@ func TestTelemetryConfigDrift_MappingTableSanity(t *testing.T) {
176184
assert.NotEmptyf(t, reason, "telemetryIgnoredOnRuntimeOnly[%q] must include a justification", path)
177185
}
178186

179-
// Stable iteration not strictly necessary for correctness, but it keeps
180-
// failure output deterministic when assertions fire on multiple keys.
181-
_ = sortedKeys(seenCRD)
182-
_ = sortedKeys(seenRuntime)
187+
// A leaf path can't be classified two different ways. An entry in both
188+
// ignore maps is a copy-paste mistake when shifting a field across the
189+
// boundary — fail loudly instead of silently allowing the contradiction.
190+
for path := range telemetryIgnoredOnCRDOnly {
191+
if _, dup := telemetryIgnoredOnRuntimeOnly[path]; dup {
192+
t.Errorf("path %q is listed in BOTH telemetryIgnoredOnCRDOnly and telemetryIgnoredOnRuntimeOnly", path)
193+
}
194+
}
195+
196+
// Every path in the mapping/ignore tables must still be a live leaf on
197+
// its respective type. Catches stale entries left behind by field
198+
// renames or deletions, which would otherwise mask the rename.
199+
crdLeaves := liveLeafSet(reflect.TypeOf(v1beta1.MCPTelemetryConfigSpec{}))
200+
for _, m := range telemetryFieldMappings {
201+
if _, live := crdLeaves[m.CRD]; !live {
202+
t.Errorf("telemetryFieldMappings entry %q is not a live leaf on v1beta1.MCPTelemetryConfigSpec — stale entry?", m.CRD)
203+
}
204+
}
205+
for path := range telemetryIgnoredOnCRDOnly {
206+
if _, live := crdLeaves[path]; !live {
207+
t.Errorf("telemetryIgnoredOnCRDOnly entry %q is not a live leaf on v1beta1.MCPTelemetryConfigSpec — stale entry?", path)
208+
}
209+
}
210+
runtimeLeaves := liveLeafSet(reflect.TypeOf(telemetry.Config{}))
211+
for _, m := range telemetryFieldMappings {
212+
if _, live := runtimeLeaves[m.Runtime]; !live {
213+
t.Errorf("telemetryFieldMappings entry %q is not a live leaf on telemetry.Config — stale entry?", m.Runtime)
214+
}
215+
}
216+
for path := range telemetryIgnoredOnRuntimeOnly {
217+
if _, live := runtimeLeaves[path]; !live {
218+
t.Errorf("telemetryIgnoredOnRuntimeOnly entry %q is not a live leaf on telemetry.Config — stale entry?", path)
219+
}
220+
}
183221
}
184222

185-
func sortedKeys[V any](m map[string]V) []string {
186-
out := make([]string, 0, len(m))
187-
for k := range m {
188-
out = append(out, k)
223+
// liveLeafSet returns the set of leaf paths reachable from t, for use in
224+
// stale-entry checks against the drift mapping/ignore tables.
225+
func liveLeafSet(t reflect.Type) map[string]struct{} {
226+
leaves := testutil.FlattenJSONLeafFields(t)
227+
out := make(map[string]struct{}, len(leaves))
228+
for _, l := range leaves {
229+
out[l] = struct{}{}
189230
}
190-
sort.Strings(out)
191231
return out
192232
}

0 commit comments

Comments
 (0)