diff --git a/internal/schema/pkl/schema/formae.pkl b/internal/schema/pkl/schema/formae.pkl index 3d823e4ab..09d3b5ce6 100644 --- a/internal/schema/pkl/schema/formae.pkl +++ b/internal/schema/pkl/schema/formae.pkl @@ -176,6 +176,14 @@ open class FieldHint extends Annotation { hidden required: Boolean = false hidden requiredOnCreate: Boolean = false hidden hasProviderDefault: Boolean = false + + /// Glob patterns for provider-injected Mapping keys. + /// When hasProviderDefault = true on a Mapping field: + /// - null/empty: tolerate ALL extra keys (broad tolerance) + /// - non-empty: only tolerate keys matching at least one pattern + /// Patterns use filepath.Match syntax (e.g., "provider.example.com/*"). + hidden providerDefaultKeyPatterns: Listing? + hidden indexField: String? hidden updateMethod: FieldUpdateMethod? @@ -189,6 +197,7 @@ open class FieldHint extends Annotation { fixed Required: Boolean = required fixed RequiredOnCreate: Boolean = requiredOnCreate fixed HasProviderDefault: Boolean = hasProviderDefault + fixed ProviderDefaultKeyPatterns: Listing? = providerDefaultKeyPatterns fixed UpdateMethod: String = updateMethod ?? "" fixed IndexField: String = indexField ?? "" } diff --git a/internal/schema/pkl/schema/tests/formae.pkl-expected.pcf b/internal/schema/pkl/schema/tests/formae.pkl-expected.pcf index 176e2dcf4..b6354514f 100644 --- a/internal/schema/pkl/schema/tests/formae.pkl-expected.pcf +++ b/internal/schema/pkl/schema/tests/formae.pkl-expected.pcf @@ -89,6 +89,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -98,6 +99,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -107,6 +109,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -127,6 +130,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -136,6 +140,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -145,6 +150,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -193,6 +199,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -202,6 +209,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -211,6 +219,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -267,6 +276,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -276,6 +286,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -285,6 +296,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -326,6 +338,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -335,6 +348,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -344,6 +358,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -353,6 +368,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -362,6 +378,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -371,6 +388,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -380,6 +398,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -416,6 +435,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -425,6 +445,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -434,6 +455,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -454,6 +476,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -463,6 +486,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -472,6 +496,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } diff --git a/pkg/model/schema.go b/pkg/model/schema.go index e0ea0f3e3..6ad9b034e 100644 --- a/pkg/model/schema.go +++ b/pkg/model/schema.go @@ -20,6 +20,12 @@ type FieldHint struct { RequiredOnCreate bool `json:"RequiredOnCreate" pkl:"RequiredOnCreate"` HasProviderDefault bool `json:"HasProviderDefault" pkl:"HasProviderDefault"` + // ProviderDefaultKeyPatterns specifies glob patterns for provider-injected + // Mapping keys. When HasProviderDefault is true on a Mapping field: + // - empty: tolerate ALL extra keys in the Mapping + // - non-empty: only tolerate keys matching at least one pattern + ProviderDefaultKeyPatterns []string `json:"ProviderDefaultKeyPatterns,omitempty" pkl:"ProviderDefaultKeyPatterns"` + IndexField string `json:"IndexField" pkl:"IndexField"` UpdateMethod FieldUpdateMethod `json:"UpdateMethod" pkl:"UpdateMethod"` } diff --git a/pkg/plugin-conformance-tests/runner.go b/pkg/plugin-conformance-tests/runner.go index 4cad782c6..5bcae2c0a 100644 --- a/pkg/plugin-conformance-tests/runner.go +++ b/pkg/plugin-conformance-tests/runner.go @@ -374,10 +374,23 @@ func normalizeResolvables(v any) any { } // extractProviderDefaultPaths extracts field paths with HasProviderDefault=true -// from the Schema Hints on a resource. Returns a set of dot-separated paths -// (e.g. "spec.ports.protocol", "spec.containers.imagePullPolicy"). -func extractProviderDefaultPaths(resource map[string]any) map[string]bool { - result := make(map[string]bool) +// from the Schema Hints on a resource. +// +// providerDefault holds metadata about a provider-default field path, +// including whether it's a collection (Mapping) that should use prefix matching. +type providerDefault struct { + // IsCollection indicates this is a Mapping field where the provider may + // add extra keys. When true, prefix matching is used — any child path + // under this field is tolerated. + IsCollection bool + // KeyPatterns specifies glob patterns for provider-injected Mapping keys. + // When non-empty, only extra keys matching at least one pattern are tolerated. + // When empty, all extra keys are tolerated (broad tolerance). + KeyPatterns []string +} + +func extractProviderDefaultPaths(resource map[string]any) map[string]providerDefault { + result := make(map[string]providerDefault) schema, ok := resource["Schema"].(map[string]any) if !ok { return result @@ -392,19 +405,89 @@ func extractProviderDefaultPaths(resource map[string]any) map[string]bool { continue } if hpd, ok := hintMap["HasProviderDefault"].(bool); ok && hpd { - result[path] = true + pd := providerDefault{} + if patterns, ok := hintMap["ProviderDefaultKeyPatterns"].([]any); ok && len(patterns) > 0 { + pd.IsCollection = true + for _, p := range patterns { + if s, ok := p.(string); ok { + pd.KeyPatterns = append(pd.KeyPatterns, s) + } + } + } + result[path] = pd + } + } + + // Detect collection (Mapping) fields from the schema structure. + // A field with hasProviderDefault that has no child fields in the schema + // is a leaf field — likely a Mapping where the provider may add extra keys. + fields, _ := schema["Fields"].([]any) + fieldSet := make(map[string]bool, len(fields)) + for _, f := range fields { + if s, ok := f.(string); ok { + fieldSet[s] = true + } + } + for path, pd := range result { + if pd.IsCollection { + continue + } + hasChildren := false + prefix := path + "." + for field := range fieldSet { + if strings.HasPrefix(field, prefix) { + hasChildren = true + break + } + } + if !hasChildren { + pd.IsCollection = true + result[path] = pd } } + return result } // isProviderDefault checks if a given field path (dot-separated) matches -// a provider default path. Handles array traversal by stripping array -// segments — e.g. path "spec.ports[0].protocol" is checked as "spec.ports.protocol". -func isProviderDefault(fieldPath string, providerDefaults map[string]bool) bool { - if providerDefaults[fieldPath] { +// a provider default path. Handles: +// - Exact match for scalar fields +// - Prefix match for collection (Mapping) fields — any child path is tolerated +// - Array index stripping — e.g. "spec.ports[0].protocol" → "spec.ports.protocol" +// - Key pattern matching — when patterns are specified, only matching keys are tolerated +func isProviderDefault(fieldPath string, providerDefaults map[string]providerDefault) bool { + // Exact match (scalar provider default) + if _, ok := providerDefaults[fieldPath]; ok { return true } + + // Prefix match for collection (Mapping) provider defaults + for path, pd := range providerDefaults { + if !pd.IsCollection { + continue + } + prefix := path + "." + if strings.HasPrefix(fieldPath, prefix) { + if len(pd.KeyPatterns) == 0 { + return true // broad tolerance — all extra keys tolerated + } + // Match the remainder after the Mapping path against key patterns. + // Try both the full remainder (for dotted patterns like "provider.example.com/*") + // and the first segment (for simple patterns like "app"). + remainder := fieldPath[len(prefix):] + for _, pattern := range pd.KeyPatterns { + if matched, _ := filepath.Match(pattern, remainder); matched { + return true + } + firstSegment := strings.SplitN(remainder, ".", 2)[0] + if matched, _ := filepath.Match(pattern, firstSegment); matched { + return true + } + } + return false + } + } + // Strip array index segments: spec.ports[0].protocol → spec.ports.protocol cleaned := fieldPath for { @@ -418,7 +501,11 @@ func isProviderDefault(fieldPath string, providerDefaults map[string]bool) bool } cleaned = cleaned[:start] + cleaned[start+end+1:] } - return providerDefaults[cleaned] + if cleaned != fieldPath { + return isProviderDefault(cleaned, providerDefaults) + } + + return false } // compareArrayUnordered compares two arrays ignoring element order. @@ -427,7 +514,7 @@ func isProviderDefault(fieldPath string, providerDefaults map[string]bool) bool // serialized to JSON for canonical comparison. Nested resolvables inside // map elements are normalized before comparison so that $visibility vs // $value differences don't cause false mismatches. -func compareArrayUnordered(t *testing.T, key string, expected, actual any, context string, providerDefaults map[string]bool) bool { +func compareArrayUnordered(t *testing.T, key string, expected, actual any, context string, providerDefaults map[string]providerDefault) bool { expectedArr, ok := expected.([]any) if !ok { t.Errorf("Expected %s is not an array (%s)", key, context) @@ -501,7 +588,7 @@ func compareArrayUnordered(t *testing.T, key string, expected, actual any, conte // all expected keys match (subset matching for finding pairs). Then it runs // bidirectional compareMap which flags extra actual keys unless they are // hasProviderDefault in the schema. -func compareArrayOfMaps(t *testing.T, key string, expectedArr, actualArr []any, context string, providerDefaults map[string]bool) bool { +func compareArrayOfMaps(t *testing.T, key string, expectedArr, actualArr []any, context string, providerDefaults map[string]providerDefault) bool { ok := true matched := make([]bool, len(actualArr)) @@ -665,7 +752,7 @@ func arraySubsetMatch(expected, actual []any) bool { // compareArrayWithResolvables compares arrays element-wise, handling resolvable // elements by matching on metadata and validating $value, and non-resolvable // elements by JSON equality. -func compareArrayWithResolvables(t *testing.T, key string, expectedArr, actualArr []any, context string, providerDefaults map[string]bool) bool { +func compareArrayWithResolvables(t *testing.T, key string, expectedArr, actualArr []any, context string, providerDefaults map[string]providerDefault) bool { ok := true matched := make([]bool, len(actualArr)) @@ -750,7 +837,7 @@ func compareArrayWithResolvables(t *testing.T, key string, expectedArr, actualAr // arrays, and sub-maps. Performs bidirectional comparison: // 1. expected→actual: all expected keys must exist and match in actual // 2. actual→expected: extra keys in actual must be hasProviderDefault in schema -func compareMap(t *testing.T, name string, expected, actual map[string]any, context string, providerDefaults map[string]bool) bool { +func compareMap(t *testing.T, name string, expected, actual map[string]any, context string, providerDefaults map[string]providerDefault) bool { ok := true // Forward: expected → actual for key, expectedValue := range expected { @@ -808,7 +895,7 @@ func compareMap(t *testing.T, name string, expected, actual map[string]any, cont // compareProperties compares expected properties against actual properties from inventory. // Uses schema hints to allow extra fields marked hasProviderDefault while flagging // any other unexpected fields in the actual response. -func compareProperties(t *testing.T, expectedProperties map[string]any, actualResource map[string]any, context string, providerDefaults map[string]bool) bool { +func compareProperties(t *testing.T, expectedProperties map[string]any, actualResource map[string]any, context string, providerDefaults map[string]providerDefault) bool { hasErrors := false actualProperties, ok := actualResource["Properties"].(map[string]any) diff --git a/pkg/plugin-conformance-tests/runner_test.go b/pkg/plugin-conformance-tests/runner_test.go index 0553f8836..5500b66d2 100644 --- a/pkg/plugin-conformance-tests/runner_test.go +++ b/pkg/plugin-conformance-tests/runner_test.go @@ -466,7 +466,7 @@ func TestCompareProperties_NestedResolvable(t *testing.T) { }, } - result := compareProperties(t, expectedProperties, actualResource, "after create", map[string]bool{}) + result := compareProperties(t, expectedProperties, actualResource, "after create", map[string]providerDefault{}) if !result { t.Errorf("compareProperties should pass when SubResource contains a nested Resolvable with resolved $value") } @@ -494,7 +494,7 @@ func TestCompareMap(t *testing.T) { "$value": "arn:aws:iam::123456789012:role/my-role", }, } - if !compareMap(t, "Config", expected, actual, "test", map[string]bool{}) { + if !compareMap(t, "Config", expected, actual, "test", map[string]providerDefault{}) { t.Error("compareMap should pass for nested resolvable") } }) @@ -507,7 +507,7 @@ func TestCompareMap(t *testing.T) { actual := map[string]any{ "Name": "different-value", } - if compareMap(fakeT, "Config", expected, actual, "test", map[string]bool{}) { + if compareMap(fakeT, "Config", expected, actual, "test", map[string]providerDefault{}) { t.Error("compareMap should fail for scalar mismatch") } }) @@ -521,7 +521,7 @@ func TestCompareMap(t *testing.T) { "Name": "my-app", "ExtraID": "extra-value", } - if compareMap(fakeT, "Config", expected, actual, "test", map[string]bool{}) { + if compareMap(fakeT, "Config", expected, actual, "test", map[string]providerDefault{}) { t.Error("compareMap should fail when actual has extra keys not marked as provider default") } }) @@ -534,8 +534,8 @@ func TestCompareMap(t *testing.T) { "Name": "my-app", "ExtraID": "extra-value", } - providerDefaults := map[string]bool{ - "Config.ExtraID": true, + providerDefaults := map[string]providerDefault{ + "Config.ExtraID": {}, } if !compareMap(t, "Config", expected, actual, "test", providerDefaults) { t.Error("compareMap should pass when extra key is a provider default") @@ -571,7 +571,7 @@ func TestCompareMap(t *testing.T) { }, }, } - if !compareMap(t, "Config", expected, actual, "test", map[string]bool{}) { + if !compareMap(t, "Config", expected, actual, "test", map[string]providerDefault{}) { t.Error("compareMap should pass for deeply nested resolvable") } }) @@ -585,7 +585,7 @@ func TestCompareMap(t *testing.T) { actual := map[string]any{ "Name": "my-app", } - if compareMap(fakeT, "Config", expected, actual, "test", map[string]bool{}) { + if compareMap(fakeT, "Config", expected, actual, "test", map[string]providerDefault{}) { t.Error("compareMap should fail when expected key is missing from actual") } }) @@ -632,7 +632,7 @@ func TestCompareProperties_ResolvableNestedInArray(t *testing.T) { }, } - result := compareProperties(t, expectedProperties, actualResource, "after create", map[string]bool{}) + result := compareProperties(t, expectedProperties, actualResource, "after create", map[string]providerDefault{}) if !result { t.Errorf("compareProperties should pass when an array element contains a nested resolvable with resolved $value") } @@ -647,9 +647,9 @@ func TestCompareArrayUnordered_ProviderDefaultsAllowed(t *testing.T) { actual := []any{ map[string]any{"name": "http", "port": float64(80), "protocol": "TCP", "targetPort": float64(80)}, } - providerDefaults := map[string]bool{ - "spec.ports.protocol": true, - "spec.ports.targetPort": true, + providerDefaults := map[string]providerDefault{ + "spec.ports.protocol": {}, + "spec.ports.targetPort": {}, } result := compareArrayUnordered(t, "spec.ports", expected, actual, "after create", providerDefaults) if !result { @@ -665,8 +665,8 @@ func TestCompareArrayUnordered_NonProviderDefaultFlagged(t *testing.T) { actual := []any{ map[string]any{"name": "http", "port": float64(80), "bogus": "bad"}, } - providerDefaults := map[string]bool{ - "spec.ports.protocol": true, + providerDefaults := map[string]providerDefault{ + "spec.ports.protocol": {}, } // Use a sub-test so the failure doesn't abort the parent inner := &testing.T{} @@ -686,9 +686,9 @@ func TestCompareArrayUnordered_MultipleElementsDifferentOrder(t *testing.T) { map[string]any{"name": "http", "port": float64(80), "protocol": "TCP", "targetPort": float64(80)}, map[string]any{"name": "https", "port": float64(443), "protocol": "TCP", "targetPort": float64(443)}, } - providerDefaults := map[string]bool{ - "spec.ports.protocol": true, - "spec.ports.targetPort": true, + providerDefaults := map[string]providerDefault{ + "spec.ports.protocol": {}, + "spec.ports.targetPort": {}, } result := compareArrayUnordered(t, "spec.ports", expected, actual, "after create", providerDefaults) if !result { @@ -718,12 +718,12 @@ func TestCompareArrayUnordered_NestedProviderDefaults(t *testing.T) { "sideEffects": "None", }, } - providerDefaults := map[string]bool{ - "webhooks.matchPolicy": true, - "webhooks.timeoutSeconds": true, - "webhooks.failurePolicy": true, - "webhooks.sideEffects": true, - "webhooks.clientConfig.service.port": true, + providerDefaults := map[string]providerDefault{ + "webhooks.matchPolicy": {}, + "webhooks.timeoutSeconds": {}, + "webhooks.failurePolicy": {}, + "webhooks.sideEffects": {}, + "webhooks.clientConfig.service.port": {}, } result := compareArrayUnordered(t, "webhooks", expected, actual, "after create", providerDefaults) if !result { @@ -751,9 +751,9 @@ func TestCompareProperties_ProviderDefaultsAllowed(t *testing.T) { }, }, } - providerDefaults := map[string]bool{ - "spec.ports.protocol": true, - "spec.ports.targetPort": true, + providerDefaults := map[string]providerDefault{ + "spec.ports.protocol": {}, + "spec.ports.targetPort": {}, } result := compareProperties(t, expectedProperties, actualResource, "after create", providerDefaults) if !result { @@ -772,8 +772,8 @@ func TestCompareProperties_ExtraTopLevelProviderDefault(t *testing.T) { "clusterIP": "10.96.0.1", }, } - providerDefaults := map[string]bool{ - "clusterIP": true, + providerDefaults := map[string]providerDefault{ + "clusterIP": {}, } result := compareProperties(t, expectedProperties, actualResource, "after create", providerDefaults) if !result { @@ -792,7 +792,7 @@ func TestCompareProperties_ExtraTopLevelNonProviderDefault(t *testing.T) { "bogus": "unexpected", }, } - providerDefaults := map[string]bool{} + providerDefaults := map[string]providerDefault{} inner := &testing.T{} result := compareProperties(inner, expectedProperties, actualResource, "after create", providerDefaults) if result { @@ -804,8 +804,8 @@ func TestCompareMap_ExtraNestedProviderDefault(t *testing.T) { // Extra nested key in a map that is a provider default expected := map[string]any{"name": "my-svc"} actual := map[string]any{"name": "my-svc", "sessionAffinity": "None"} - providerDefaults := map[string]bool{ - "spec.sessionAffinity": true, + providerDefaults := map[string]providerDefault{ + "spec.sessionAffinity": {}, } result := compareMap(t, "spec", expected, actual, "after create", providerDefaults) if !result { @@ -817,7 +817,7 @@ func TestCompareMap_ExtraNestedNonProviderDefault(t *testing.T) { // Extra nested key that is NOT a provider default — should fail expected := map[string]any{"name": "my-svc"} actual := map[string]any{"name": "my-svc", "unknown": "bad"} - providerDefaults := map[string]bool{} + providerDefaults := map[string]providerDefault{} inner := &testing.T{} result := compareMap(inner, "spec", expected, actual, "after create", providerDefaults) if result { @@ -826,31 +826,139 @@ func TestCompareMap_ExtraNestedNonProviderDefault(t *testing.T) { } func TestIsProviderDefault(t *testing.T) { - providerDefaults := map[string]bool{ - "spec.ports.protocol": true, - "spec.sessionAffinity": true, - "webhooks.matchPolicy": true, - } + t.Run("scalar provider defaults", func(t *testing.T) { + providerDefaults := map[string]providerDefault{ + "spec.ports.protocol": {}, + "spec.sessionAffinity": {}, + "webhooks.matchPolicy": {}, + } - tests := []struct { - path string - expected bool - }{ - {"spec.ports.protocol", true}, - {"spec.ports[0].protocol", true}, - {"spec.ports[12].protocol", true}, - {"spec.sessionAffinity", true}, - {"webhooks[0].matchPolicy", true}, - {"spec.ports.bogus", false}, - {"spec.unknown", false}, - {"bogus", false}, - } - for _, tc := range tests { - got := isProviderDefault(tc.path, providerDefaults) - if got != tc.expected { - t.Errorf("isProviderDefault(%q) = %v, want %v", tc.path, got, tc.expected) + tests := []struct { + path string + expected bool + }{ + {"spec.ports.protocol", true}, + {"spec.ports[0].protocol", true}, + {"spec.ports[12].protocol", true}, + {"spec.sessionAffinity", true}, + {"webhooks[0].matchPolicy", true}, + {"spec.ports.bogus", false}, + {"spec.unknown", false}, + {"bogus", false}, } - } + for _, tc := range tests { + got := isProviderDefault(tc.path, providerDefaults) + if got != tc.expected { + t.Errorf("isProviderDefault(%q) = %v, want %v", tc.path, got, tc.expected) + } + } + }) + + t.Run("collection provider defaults - broad tolerance", func(t *testing.T) { + providerDefaults := map[string]providerDefault{ + "metadata.labels": {IsCollection: true}, + "spec.selector": {IsCollection: true}, + "spec.revisionHistory": {}, // scalar, not collection + } + + tests := []struct { + path string + expected bool + }{ + // Collection: any child key tolerated + {"metadata.labels", true}, + {"metadata.labels.app", true}, + {"metadata.labels.env", true}, + {"metadata.labels.app.kubernetes.io/name", true}, + {"spec.selector.app", true}, + {"spec.selector.matchLabels", true}, + // Scalar: only exact match + {"spec.revisionHistory", true}, + {"spec.revisionHistory.limit", false}, + // Non-defaults: still flagged + {"metadata.annotations", false}, + {"spec.unknown", false}, + } + for _, tc := range tests { + got := isProviderDefault(tc.path, providerDefaults) + if got != tc.expected { + t.Errorf("isProviderDefault(%q) = %v, want %v", tc.path, got, tc.expected) + } + } + }) + + t.Run("collection provider defaults - simple key patterns", func(t *testing.T) { + providerDefaults := map[string]providerDefault{ + "metadata.labels": { + IsCollection: true, + KeyPatterns: []string{"app", "helm"}, + }, + } + + tests := []struct { + path string + expected bool + }{ + {"metadata.labels.app", true}, + {"metadata.labels.helm", true}, + {"metadata.labels.app.example.com/name", true}, // first segment "app" matches + {"metadata.labels.env", false}, + {"metadata.labels.random", false}, + {"metadata.labels", true}, + } + for _, tc := range tests { + got := isProviderDefault(tc.path, providerDefaults) + if got != tc.expected { + t.Errorf("isProviderDefault(%q) = %v, want %v", tc.path, got, tc.expected) + } + } + }) + + t.Run("collection provider defaults - dotted key patterns", func(t *testing.T) { + providerDefaults := map[string]providerDefault{ + "metadata.labels": { + IsCollection: true, + KeyPatterns: []string{"provider.example.com/*", "mgmt.example.com/*"}, + }, + } + + tests := []struct { + path string + expected bool + }{ + {"metadata.labels.provider.example.com/name", true}, + {"metadata.labels.provider.example.com/instance", true}, + {"metadata.labels.mgmt.example.com/chart", true}, + {"metadata.labels.other.example.com/job-name", false}, + {"metadata.labels.env", false}, + } + for _, tc := range tests { + got := isProviderDefault(tc.path, providerDefaults) + if got != tc.expected { + t.Errorf("isProviderDefault(%q) = %v, want %v", tc.path, got, tc.expected) + } + } + }) + + t.Run("collection provider defaults with array indices", func(t *testing.T) { + providerDefaults := map[string]providerDefault{ + "spec.template.metadata.labels": {IsCollection: true}, + } + + tests := []struct { + path string + expected bool + }{ + {"spec.template.metadata.labels.app", true}, + {"spec.template.metadata.labels", true}, + } + for _, tc := range tests { + got := isProviderDefault(tc.path, providerDefaults) + if got != tc.expected { + t.Errorf("isProviderDefault(%q) = %v, want %v", tc.path, got, tc.expected) + } + } + }) } func TestGetTestType(t *testing.T) {