diff --git a/cmd/thv-operator/internal/testutil/reflect.go b/cmd/thv-operator/internal/testutil/reflect.go new file mode 100644 index 0000000000..8f58e19486 --- /dev/null +++ b/cmd/thv-operator/internal/testutil/reflect.go @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package testutil provides reflection-based helpers used by drift-detection +// tests in the operator. It is intended for test code only. +package testutil + +import ( + "encoding/json" + "reflect" + "sort" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// FlattenJSONLeafFields returns every leaf JSON field path under t as a sorted +// slice of dot-delimited paths (e.g. "openTelemetry.tracing.enabled"). +// +// A leaf is any type that does not produce nested JSON keys at runtime: a +// primitive, a slice/map of primitives, or a type implementing json.Marshaler +// (whose MarshalJSON shape is opaque to reflection). Structs, slices/maps of +// structs, and pointers to either are recursed into. Field names follow +// encoding/json rules including `,inline` and anonymous-field promotion. +// Self-referential types stop on revisit so the walk always terminates. +// +// If t is nil or, after dereferencing, is not a struct, an empty slice is +// returned rather than panicking. +func FlattenJSONLeafFields(t reflect.Type) []string { + if t == nil { + return []string{} + } + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return []string{} + } + + leafSet := make(map[string]struct{}) + visited := map[reflect.Type]struct{}{} + recurseStruct(t, "", leafSet, visited) + + out := make([]string, 0, len(leafSet)) + for p := range leafSet { + out = append(out, p) + } + sort.Strings(out) + return out +} + +// jsonMarshalerType is the reflect.Type of the json.Marshaler interface. Any +// type implementing it (directly or via pointer receiver) produces a JSON +// shape detached from its Go field layout, so it must terminate the walk. +var jsonMarshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem() + +// implementsJSONMarshaler reports whether values of t (or *t) have a custom +// MarshalJSON method. The pointer-receiver check matters because Go method +// sets only include pointer-receiver methods when the receiver is addressable. +func implementsJSONMarshaler(t reflect.Type) bool { + return t.Implements(jsonMarshalerType) || reflect.PointerTo(t).Implements(jsonMarshalerType) +} + +// skipFieldTypes lists embedded struct types that must be skipped entirely. +var skipFieldTypes = map[reflect.Type]struct{}{ + reflect.TypeOf(metav1.TypeMeta{}): {}, + reflect.TypeOf(metav1.ObjectMeta{}): {}, + reflect.TypeOf(metav1.ListMeta{}): {}, +} + +// walkStruct recurses into a struct type and adds leaf paths to leafSet. +// prefix is the dot-delimited path accumulated so far (without trailing dot). +// visited is the set of struct types currently on the recursion stack; it is +// used to break cycles on self-referential types. Callers add t to visited +// before invoking this function and remove it afterwards. +func walkStruct(t reflect.Type, prefix string, leafSet map[string]struct{}, visited map[reflect.Type]struct{}) { + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + // Skip unexported fields. PkgPath is non-empty for unexported fields. + // Anonymous (embedded) fields have an empty PkgPath only when their + // underlying type is exported, so the same check works for them. + if field.PkgPath != "" && !field.Anonymous { + continue + } + + // Skip explicit skip-list types (TypeMeta/ObjectMeta/ListMeta) when + // embedded. + if _, skip := skipFieldTypes[field.Type]; skip { + continue + } + + name, inline, omit := parseJSONTag(field) + if omit { + continue + } + + // Determine the path segment for this field. + segment := func() string { + // Anonymous fields are inlined by encoding/json when they have no + // json name (or an explicit `,inline` tag). Treat them as inline. + if field.Anonymous && (name == "" || inline) { + return "" + } + if inline { + return "" + } + if name == "" { + return field.Name + } + return name + }() + + childPrefix := joinPath(prefix, segment) + walkType(field.Type, childPrefix, leafSet, visited) + } +} + +// walkType walks a single type with the given accumulated prefix. It either +// recurses into nested structs/slices/maps or records a leaf at prefix. +func walkType(t reflect.Type, prefix string, leafSet map[string]struct{}, visited map[reflect.Type]struct{}) { + // Deref pointers. + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + + // Custom JSON marshalers short-circuit any further walking. See + // jsonMarshalerType for the rationale. + if implementsJSONMarshaler(t) { + recordLeaf(prefix, leafSet) + return + } + + // Only Struct/Slice/Array/Map require recursion; every other Kind + // (primitives, interfaces, channels, etc.) is a leaf path captured by + // the default branch. + switch t.Kind() { //nolint:exhaustive // every other Kind falls through to the default leaf branch by design + case reflect.Struct: + recurseStruct(t, prefix, leafSet, visited) + case reflect.Slice, reflect.Array: + elem := t.Elem() + for elem.Kind() == reflect.Pointer { + elem = elem.Elem() + } + // Recurse only when the element is a plain struct (no custom marshaler). + if elem.Kind() == reflect.Struct && !implementsJSONMarshaler(elem) { + recurseStruct(elem, prefix, leafSet, visited) + return + } + recordLeaf(prefix, leafSet) + case reflect.Map: + val := t.Elem() + for val.Kind() == reflect.Pointer { + val = val.Elem() + } + if val.Kind() == reflect.Struct && !implementsJSONMarshaler(val) { + recurseStruct(val, prefix, leafSet, visited) + return + } + recordLeaf(prefix, leafSet) + default: + recordLeaf(prefix, leafSet) + } +} + +// recurseStruct descends into a nested struct type with cycle protection. +// visited is the set of struct types currently on the recursion stack; if t +// is already present, the walk stops silently to keep self-referential types +// from looping forever. +func recurseStruct(t reflect.Type, prefix string, leafSet map[string]struct{}, visited map[reflect.Type]struct{}) { + if _, seen := visited[t]; seen { + return + } + visited[t] = struct{}{} + defer delete(visited, t) + walkStruct(t, prefix, leafSet, visited) +} + +// parseJSONTag extracts (name, inline, omit) from the json struct tag. +// - name is the explicit JSON name (empty when not specified). +// - inline is true when the tag includes ",inline". +// - omit is true when the tag is "-" (field must be skipped entirely). +func parseJSONTag(field reflect.StructField) (string, bool, bool) { + tag, ok := field.Tag.Lookup("json") + if !ok { + return "", false, false + } + if tag == "-" { + return "", false, true + } + parts := strings.Split(tag, ",") + name := parts[0] + inline := false + for _, p := range parts[1:] { + if p == "inline" { + inline = true + } + } + return name, inline, false +} + +// joinPath joins prefix and segment with a dot, handling empty segments +// (e.g. inline fields) by returning the prefix unchanged. +func joinPath(prefix, segment string) string { + if segment == "" { + return prefix + } + if prefix == "" { + return segment + } + return prefix + "." + segment +} + +// recordLeaf records prefix as a leaf path. An empty prefix is ignored +// because it would not represent a real field. +func recordLeaf(prefix string, leafSet map[string]struct{}) { + if prefix == "" { + return + } + leafSet[prefix] = struct{}{} +} diff --git a/cmd/thv-operator/internal/testutil/reflect_test.go b/cmd/thv-operator/internal/testutil/reflect_test.go new file mode 100644 index 0000000000..0a023730b4 --- /dev/null +++ b/cmd/thv-operator/internal/testutil/reflect_test.go @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import ( + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// --- Fixture types --------------------------------------------------------- + +type flatPrimitives struct { + Name string `json:"name"` + Count int `json:"count"` + Enabled bool `json:"enabled"` +} + +type withSkippedTag struct { + Visible string `json:"visible"` + Hidden string `json:"-"` +} + +type noJSONTag struct { + Visible string +} + +type withOmitempty struct { + Maybe string `json:"maybe,omitempty"` +} + +type leafInner struct { + A string `json:"a"` + B string `json:"b"` +} + +type withPointerStruct struct { + Inner *leafInner `json:"inner"` +} + +type withSliceOfStruct struct { + Items []leafInner `json:"items"` +} + +type withSliceOfPrimitive struct { + Tags []string `json:"tags"` +} + +type withMapStructValue struct { + ByKey map[string]*leafInner `json:"byKey"` +} + +type withMapPrimitiveValue struct { + Labels map[string]string `json:"labels"` +} + +type embedSource struct { + Foo string `json:"foo"` +} + +// withEmbeddedNoInline embeds a struct without an explicit `,inline` tag. +// In Go's encoding/json semantics, anonymous fields with no json tag have +// their exported fields promoted into the parent — equivalent to inline. +type withEmbeddedNoInline struct { + embedSource + Bar string `json:"bar"` +} + +type withEmbeddedInline struct { + embedSource `json:",inline"` //nolint:revive // inline is a valid kubernetes json tag option + Bar string `json:"bar"` +} + +type withUnexportedField struct { + Visible string `json:"visible"` + hidden string //nolint:unused // exercised by reflection +} + +// withDuration covers two leaf paths in one fixture: a primitive int64 +// (time.Duration, default branch) and a json.Marshaler (metav1.Duration, +// short-circuit branch). Other Marshaler types follow the same code path. +type withDuration struct { + Wait time.Duration `json:"wait"` + WaitMeta metav1.Duration `json:"waitMeta"` +} + +type recursive struct { + Name string `json:"name"` + Next *recursive `json:"next,omitempty"` +} + +// --- Tests ----------------------------------------------------------------- + +func TestFlattenJSONLeafFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in any + want []string + }{ + { + name: "flat primitives", + in: flatPrimitives{}, + want: []string{"count", "enabled", "name"}, + }, + { + name: "json:\"-\" field is skipped", + in: withSkippedTag{}, + want: []string{"visible"}, + }, + { + name: "missing json tag uses Go field name", + in: noJSONTag{}, + want: []string{"Visible"}, + }, + { + name: "omitempty does not appear in path", + in: withOmitempty{}, + want: []string{"maybe"}, + }, + { + name: "pointer field recurses", + in: withPointerStruct{}, + want: []string{"inner.a", "inner.b"}, + }, + { + name: "slice of struct recurses into element", + in: withSliceOfStruct{}, + want: []string{"items.a", "items.b"}, + }, + { + name: "slice of primitive terminates at slice", + in: withSliceOfPrimitive{}, + want: []string{"tags"}, + }, + { + name: "map with struct value recurses into value type", + in: withMapStructValue{}, + want: []string{"byKey.a", "byKey.b"}, + }, + { + name: "map with primitive value terminates at map", + in: withMapPrimitiveValue{}, + want: []string{"labels"}, + }, + { + name: "embedded struct without ,inline still flattens", + in: withEmbeddedNoInline{}, + want: []string{"bar", "foo"}, + }, + { + name: "embedded struct with ,inline flattens", + in: withEmbeddedInline{}, + want: []string{"bar", "foo"}, + }, + { + name: "unexported field is skipped", + in: withUnexportedField{}, + want: []string{"visible"}, + }, + { + // time.Duration is a primitive int64 (default branch); + // metav1.Duration is a json.Marshaler (short-circuit branch). + // Together these exercise both leaf-emission paths. + name: "primitive and json.Marshaler types are leaves", + in: withDuration{}, + want: []string{"wait", "waitMeta"}, + }, + { + name: "self-referential type stops on revisit", + in: recursive{}, + want: []string{"name"}, + }, + { + name: "pointer input is dereferenced", + in: &flatPrimitives{}, + want: []string{"count", "enabled", "name"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := FlattenJSONLeafFields(reflect.TypeOf(tc.in)) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestFlattenJSONLeafFields_NilOrNonStructReturnsEmpty(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in reflect.Type + }{ + {name: "nil reflect.Type", in: nil}, + {name: "primitive int", in: reflect.TypeOf(0)}, + {name: "string", in: reflect.TypeOf("")}, + {name: "slice", in: reflect.TypeOf([]string{})}, + {name: "pointer to primitive", in: reflect.TypeOf((*int)(nil))}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := FlattenJSONLeafFields(tc.in) + assert.Empty(t, got) + }) + } +} + +func TestFlattenJSONLeafFields_SkipsObjectMetaAndTypeMeta(t *testing.T) { + t.Parallel() + + type withK8sMeta struct { + metav1.TypeMeta `json:",inline"` //nolint:revive // inline is a valid kubernetes json tag option + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec flatPrimitives `json:"spec"` + } + + got := FlattenJSONLeafFields(reflect.TypeOf(withK8sMeta{})) + // Should only contain the spec.* fields; nothing from TypeMeta/ObjectMeta. + require.Equal(t, []string{"spec.count", "spec.enabled", "spec.name"}, got) +} diff --git a/cmd/thv-operator/pkg/spectoconfig/telemetry_drift_test.go b/cmd/thv-operator/pkg/spectoconfig/telemetry_drift_test.go new file mode 100644 index 0000000000..1c4359469d --- /dev/null +++ b/cmd/thv-operator/pkg/spectoconfig/telemetry_drift_test.go @@ -0,0 +1,232 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package spectoconfig + +// This file holds drift-detection tests that compare the CRD-side telemetry +// configuration type (v1beta1.MCPTelemetryConfigSpec) against its runtime +// counterpart (telemetry.Config). The goal is to fail any time a new leaf +// field is added on either side without an explicit decision about whether +// the field should be mirrored across the boundary or intentionally only +// exists on one side. +// +// The test uses three declarative tables as the source of truth: +// +// * telemetryFieldMappings — leaf fields present on BOTH sides, +// paired by their dot-delimited paths. +// * telemetryIgnoredOnCRDOnly — leaf fields that only exist on the +// CRD side, with a justification. +// * telemetryIgnoredOnRuntimeOnly — leaf fields that only exist on the +// runtime side, with a justification. +// +// When either side gains or loses a field, exactly one of these tables must +// be updated. The "Mapping table sanity" test guards the tables themselves +// (no duplicates, no empty entries, no overlap with the ignore lists). + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1" + "github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil" + "github.com/stacklok/toolhive/pkg/telemetry" +) + +// FieldMapping pairs a CRD-side leaf path with a runtime-side leaf path. Both +// sides must be present unless the field is in one of the ignore maps. +type FieldMapping struct { + CRD string + Runtime string +} + +// telemetryFieldMappings is the source of truth for CRD<->runtime field +// links. One entry per leaf field that exists on both sides. The CRD path is +// rooted at MCPTelemetryConfigSpec; the runtime path is rooted at +// telemetry.Config. +var telemetryFieldMappings = []FieldMapping{ + {CRD: "openTelemetry.endpoint", Runtime: "endpoint"}, + {CRD: "openTelemetry.insecure", Runtime: "insecure"}, + {CRD: "openTelemetry.headers", Runtime: "headers"}, + {CRD: "openTelemetry.resourceAttributes", Runtime: "customAttributes"}, + {CRD: "openTelemetry.tracing.enabled", Runtime: "tracingEnabled"}, + {CRD: "openTelemetry.tracing.samplingRate", Runtime: "samplingRate"}, + {CRD: "openTelemetry.metrics.enabled", Runtime: "metricsEnabled"}, + {CRD: "openTelemetry.useLegacyAttributes", Runtime: "useLegacyAttributes"}, + {CRD: "prometheus.enabled", Runtime: "enablePrometheusMetricsPath"}, +} + +// telemetryIgnoredOnCRDOnly lists CRD leaf fields that intentionally have no +// runtime counterpart. Each entry MUST include a justification. +var telemetryIgnoredOnCRDOnly = map[string]string{ + "openTelemetry.enabled": "CRD-only gate; controls whether the converter populates runtime fields at all", + "openTelemetry.sensitiveHeaders.name": "K8s-secret-backed header value; injected as TOOLHIVE_OTEL_HEADER_* env vars on the proxyrunner pod " + + "(controllerutil.GenerateOpenTelemetryEnvVarsFromRef) and merged into OTLP headers at runtime; not written into telemetry.Config.Headers by the converter", + "openTelemetry.sensitiveHeaders.secretKeyRef.name": "K8s-secret-backed header value; injected as TOOLHIVE_OTEL_HEADER_* env vars on the proxyrunner pod " + + "(controllerutil.GenerateOpenTelemetryEnvVarsFromRef) and merged into OTLP headers at runtime; not written into telemetry.Config.Headers by the converter", + "openTelemetry.sensitiveHeaders.secretKeyRef.key": "K8s-secret-backed header value; injected as TOOLHIVE_OTEL_HEADER_* env vars on the proxyrunner pod " + + "(controllerutil.GenerateOpenTelemetryEnvVarsFromRef) and merged into OTLP headers at runtime; not written into telemetry.Config.Headers by the converter", + "openTelemetry.caBundleRef.configMapRef.name": "K8s ConfigMap reference; resolved by operator into runtime CACertPath", + "openTelemetry.caBundleRef.configMapRef.key": "K8s ConfigMap reference; resolved by operator into runtime CACertPath", + "openTelemetry.caBundleRef.configMapRef.optional": "K8s ConfigMap reference flag promoted from corev1.ConfigMapKeySelector; not part of runtime config", +} + +// telemetryIgnoredOnRuntimeOnly lists runtime leaf fields that intentionally +// have no CRD counterpart. Each entry MUST include a justification. +var telemetryIgnoredOnRuntimeOnly = map[string]string{ + "serviceName": "per-server, set from MCPTelemetryConfigReference.ServiceName and defaulted at runtime by " + + "telemetry.ResolveServiceName; intentionally absent from the shared MCPTelemetryConfig", + "serviceVersion": "resolved at runtime from binary version (issue #2296)", + "environmentVariables": "CLI-only, not applicable to CRD-managed telemetry", + "caCertPath": "filesystem path injected by runconfig.AppendTelemetryRunnerOption after the operator computes the volume mount " + + "path from openTelemetry.caBundleRef; not user-facing in the CRD", +} + +// TestTelemetryConfigDrift_CRDFieldsCovered walks MCPTelemetryConfigSpec and +// requires every leaf path to appear either as a CRD entry in +// telemetryFieldMappings or as a key in telemetryIgnoredOnCRDOnly. +func TestTelemetryConfigDrift_CRDFieldsCovered(t *testing.T) { + t.Parallel() + + mappedCRD := make(map[string]struct{}, len(telemetryFieldMappings)) + for _, m := range telemetryFieldMappings { + mappedCRD[m.CRD] = struct{}{} + } + + leaves := testutil.FlattenJSONLeafFields(reflect.TypeOf(v1beta1.MCPTelemetryConfigSpec{})) + for _, leaf := range leaves { + if _, ok := mappedCRD[leaf]; ok { + continue + } + if _, ok := telemetryIgnoredOnCRDOnly[leaf]; ok { + continue + } + t.Errorf( + "v1beta1.MCPTelemetryConfigSpec field %q is unclassified.\n"+ + "Action: add it to telemetryFieldMappings (with the corresponding telemetry.Config path)\n"+ + " OR add it to telemetryIgnoredOnCRDOnly with a justification string.", + leaf, + ) + } +} + +// TestTelemetryConfigDrift_RuntimeFieldsCovered walks telemetry.Config and +// requires every leaf path to appear either as a Runtime entry in +// telemetryFieldMappings or as a key in telemetryIgnoredOnRuntimeOnly. +func TestTelemetryConfigDrift_RuntimeFieldsCovered(t *testing.T) { + t.Parallel() + + mappedRuntime := make(map[string]struct{}, len(telemetryFieldMappings)) + for _, m := range telemetryFieldMappings { + mappedRuntime[m.Runtime] = struct{}{} + } + + leaves := testutil.FlattenJSONLeafFields(reflect.TypeOf(telemetry.Config{})) + for _, leaf := range leaves { + if _, ok := mappedRuntime[leaf]; ok { + continue + } + if _, ok := telemetryIgnoredOnRuntimeOnly[leaf]; ok { + continue + } + t.Errorf( + "telemetry.Config field %q is unclassified.\n"+ + "Action: add it to telemetryFieldMappings (with the corresponding MCPTelemetryConfigSpec path)\n"+ + " OR add it to telemetryIgnoredOnRuntimeOnly with a justification string.", + leaf, + ) + } +} + +// TestTelemetryConfigDrift_MappingTableSanity guards the mapping tables +// themselves. It catches mistakes like duplicate paths, empty entries, and +// overlap between mapped and ignored fields. +func TestTelemetryConfigDrift_MappingTableSanity(t *testing.T) { + t.Parallel() + + seenCRD := make(map[string]int, len(telemetryFieldMappings)) + seenRuntime := make(map[string]int, len(telemetryFieldMappings)) + + // Use require for the per-entry NotEmpty checks so that an empty CRD + // or Runtime field doesn't pollute the duplicate maps below with an + // empty-string key — that would trigger a misleading cascade. + for i, m := range telemetryFieldMappings { + require.NotEmptyf(t, m.CRD, "telemetryFieldMappings[%d].CRD must not be empty", i) + require.NotEmptyf(t, m.Runtime, "telemetryFieldMappings[%d].Runtime must not be empty", i) + seenCRD[m.CRD]++ + seenRuntime[m.Runtime]++ + } + + for path, count := range seenCRD { + assert.Equalf(t, 1, count, "CRD path %q appears %d times in telemetryFieldMappings", path, count) + } + for path, count := range seenRuntime { + assert.Equalf(t, 1, count, "runtime path %q appears %d times in telemetryFieldMappings", path, count) + } + + // Overlap with ignore lists. + for _, m := range telemetryFieldMappings { + if _, dup := telemetryIgnoredOnCRDOnly[m.CRD]; dup { + t.Errorf("CRD path %q is both mapped and listed in telemetryIgnoredOnCRDOnly", m.CRD) + } + if _, dup := telemetryIgnoredOnRuntimeOnly[m.Runtime]; dup { + t.Errorf("runtime path %q is both mapped and listed in telemetryIgnoredOnRuntimeOnly", m.Runtime) + } + } + + // Justifications must be non-empty. + for path, reason := range telemetryIgnoredOnCRDOnly { + assert.NotEmptyf(t, reason, "telemetryIgnoredOnCRDOnly[%q] must include a justification", path) + } + for path, reason := range telemetryIgnoredOnRuntimeOnly { + assert.NotEmptyf(t, reason, "telemetryIgnoredOnRuntimeOnly[%q] must include a justification", path) + } + + // A leaf path can't be classified two different ways. An entry in both + // ignore maps is a copy-paste mistake when shifting a field across the + // boundary — fail loudly instead of silently allowing the contradiction. + for path := range telemetryIgnoredOnCRDOnly { + if _, dup := telemetryIgnoredOnRuntimeOnly[path]; dup { + t.Errorf("path %q is listed in BOTH telemetryIgnoredOnCRDOnly and telemetryIgnoredOnRuntimeOnly", path) + } + } + + // Every path in the mapping/ignore tables must still be a live leaf on + // its respective type. Catches stale entries left behind by field + // renames or deletions, which would otherwise mask the rename. + crdLeaves := liveLeafSet(reflect.TypeOf(v1beta1.MCPTelemetryConfigSpec{})) + for _, m := range telemetryFieldMappings { + if _, live := crdLeaves[m.CRD]; !live { + t.Errorf("telemetryFieldMappings entry %q is not a live leaf on v1beta1.MCPTelemetryConfigSpec — stale entry?", m.CRD) + } + } + for path := range telemetryIgnoredOnCRDOnly { + if _, live := crdLeaves[path]; !live { + t.Errorf("telemetryIgnoredOnCRDOnly entry %q is not a live leaf on v1beta1.MCPTelemetryConfigSpec — stale entry?", path) + } + } + runtimeLeaves := liveLeafSet(reflect.TypeOf(telemetry.Config{})) + for _, m := range telemetryFieldMappings { + if _, live := runtimeLeaves[m.Runtime]; !live { + t.Errorf("telemetryFieldMappings entry %q is not a live leaf on telemetry.Config — stale entry?", m.Runtime) + } + } + for path := range telemetryIgnoredOnRuntimeOnly { + if _, live := runtimeLeaves[path]; !live { + t.Errorf("telemetryIgnoredOnRuntimeOnly entry %q is not a live leaf on telemetry.Config — stale entry?", path) + } + } +} + +// liveLeafSet returns the set of leaf paths reachable from t, for use in +// stale-entry checks against the drift mapping/ignore tables. +func liveLeafSet(t reflect.Type) map[string]struct{} { + leaves := testutil.FlattenJSONLeafFields(t) + out := make(map[string]struct{}, len(leaves)) + for _, l := range leaves { + out[l] = struct{}{} + } + return out +}