From b0dddbf3091aa2e2b17d703839505c2f580ff0e1 Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:13:22 +0100 Subject: [PATCH 1/6] feat(conformance): collection-aware hasProviderDefault for Mapping fields Extend hasProviderDefault to support Mapping fields via prefix matching. When a Mapping field (e.g., labels, annotations) has hasProviderDefault=true, extra keys injected by the provider are tolerated in conformance tests. This solves the dot-expansion problem where K8S labels like app.kubernetes.io/name get expanded into nested paths (metadata.labels.app) that the conformance test flags as unexpected properties. Changes: - Add providerDefaultKeyPatterns to FieldHint (PKL + Go) for optional glob-based filtering of which extra keys to tolerate - Auto-detect Mapping fields from actual properties for broad tolerance - Add prefix matching in isProviderDefault() for collection fields - Update all test signatures and add 4 new test cases covering: broad tolerance, key patterns, array indices, and nested paths --- internal/schema/pkl/schema/formae.pkl | 8 + pkg/model/schema.go | 6 + pkg/plugin-conformance-tests/runner.go | 116 ++++++++++-- pkg/plugin-conformance-tests/runner_test.go | 194 ++++++++++++++------ 4 files changed, 256 insertions(+), 68 deletions(-) diff --git a/internal/schema/pkl/schema/formae.pkl b/internal/schema/pkl/schema/formae.pkl index d4898dfe8..da46f04c8 100644 --- a/internal/schema/pkl/schema/formae.pkl +++ b/internal/schema/pkl/schema/formae.pkl @@ -177,6 +177,13 @@ open class FieldHint extends Annotation { hidden indexField: String? hidden updateMethod: FieldUpdateMethod? + /// 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., "app.kubernetes.io/*"). + hidden providerDefaultKeyPatterns: Listing? + hidden outputField: String? hidden outputTransformation: ((Any) -> Any)? hidden outputCondition: ((Any) -> Boolean) = (_) -> true @@ -187,6 +194,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/pkg/model/schema.go b/pkg/model/schema.go index c1fe1aed6..b3dac1323 100644 --- a/pkg/model/schema.go +++ b/pkg/model/schema.go @@ -19,6 +19,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..ecc891dc6 100644 --- a/pkg/plugin-conformance-tests/runner.go +++ b/pkg/plugin-conformance-tests/runner.go @@ -376,8 +376,22 @@ 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) +// providerDefault holds metadata about a provider-default field path, +// including whether it's a collection (Mapping) that should use prefix matching +// and optional key patterns for fine-grained control. +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. + 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 +406,91 @@ func extractProviderDefaultPaths(resource map[string]any) map[string]bool { continue } if hpd, ok := hintMap["HasProviderDefault"].(bool); ok && hpd { - result[path] = true + pd := providerDefault{} + // Check if this field has key patterns (indicates a collection/Mapping field) + 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 fields by checking if the actual properties at this path + // contain a map value. This enables broad tolerance (Phase 1) for Mapping fields + // with hasProviderDefault=true even without explicit key patterns. + if props, ok := resource["Properties"].(map[string]any); ok { + for path, pd := range result { + if pd.IsCollection { + continue // already marked via key patterns + } + if isMapAtPath(props, path) { + pd.IsCollection = true + result[path] = pd + } + } + } + return result } +// isMapAtPath checks if the value at a dot-separated path in a nested map is itself a map. +func isMapAtPath(obj map[string]any, path string) bool { + parts := strings.Split(path, ".") + current := any(obj) + for _, part := range parts { + m, ok := current.(map[string]any) + if !ok { + return false + } + current = m[part] + } + _, isMap := current.(map[string]any) + return isMap +} + // 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 + } + // Extract the immediate key after the Mapping path for pattern matching. + // For fieldPath "metadata.labels.app" with Mapping at "metadata.labels", + // the key is "app". However, the original JSON key may have been + // "app.kubernetes.io/name" which was dot-expanded. We match against + // the first segment after the prefix. + remainder := fieldPath[len(prefix):] + key := strings.SplitN(remainder, ".", 2)[0] + for _, pattern := range pd.KeyPatterns { + if matched, _ := filepath.Match(pattern, key); matched { + return true + } + } + return false + } + } + // Strip array index segments: spec.ports[0].protocol → spec.ports.protocol cleaned := fieldPath for { @@ -418,7 +504,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 +517,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 +591,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 +755,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 +840,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 +898,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..2b6d78fe0 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,115 @@ 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 - key patterns", func(t *testing.T) { + providerDefaults := map[string]providerDefault{ + "metadata.labels": { + IsCollection: true, + KeyPatterns: []string{"app", "helm"}, + }, + } + + tests := []struct { + path string + expected bool + }{ + // Matching patterns + {"metadata.labels.app", true}, + {"metadata.labels.helm", true}, + // Non-matching patterns + {"metadata.labels.env", false}, + {"metadata.labels.random", false}, + // Exact match on the collection itself + {"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 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) { From 36f979fcd34662cd2cfc0ce6687dff652df7ac55 Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:22:42 +0100 Subject: [PATCH 2/6] fix(conformance): support dotted key patterns in collection provider defaults Key patterns now match against both the full remainder after the Mapping path (for dotted patterns like "app.kubernetes.io/*") and the first segment (for simple patterns like "app"). This allows patterns to match the original JSON key before Formae's dot-expansion splits it. Adds test case for dotted key patterns verifying that "app.kubernetes.io/*" matches "metadata.labels.app.kubernetes.io/name". --- pkg/plugin-conformance-tests/runner.go | 20 +++++++----- pkg/plugin-conformance-tests/runner_test.go | 35 +++++++++++++++++++-- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/pkg/plugin-conformance-tests/runner.go b/pkg/plugin-conformance-tests/runner.go index ecc891dc6..9968ee5ed 100644 --- a/pkg/plugin-conformance-tests/runner.go +++ b/pkg/plugin-conformance-tests/runner.go @@ -475,15 +475,21 @@ func isProviderDefault(fieldPath string, providerDefaults map[string]providerDef if len(pd.KeyPatterns) == 0 { return true // broad tolerance — all extra keys tolerated } - // Extract the immediate key after the Mapping path for pattern matching. - // For fieldPath "metadata.labels.app" with Mapping at "metadata.labels", - // the key is "app". However, the original JSON key may have been - // "app.kubernetes.io/name" which was dot-expanded. We match against - // the first segment after the prefix. + // Match the remainder after the Mapping path against key patterns. + // The remainder may contain dots from dot-expansion of the original + // JSON key (e.g., "app.kubernetes.io/name" → remainder "app.kubernetes.io/name"). + // We try matching both the full remainder and the first segment to + // support both dotted patterns ("app.kubernetes.io/*") and simple + // patterns ("app"). remainder := fieldPath[len(prefix):] - key := strings.SplitN(remainder, ".", 2)[0] for _, pattern := range pd.KeyPatterns { - if matched, _ := filepath.Match(pattern, key); matched { + // Match against full remainder (supports dotted patterns like "app.kubernetes.io/*") + if matched, _ := filepath.Match(pattern, remainder); matched { + return true + } + // Match against first segment (supports simple patterns like "app") + firstSegment := strings.SplitN(remainder, ".", 2)[0] + if matched, _ := filepath.Match(pattern, firstSegment); matched { return true } } diff --git a/pkg/plugin-conformance-tests/runner_test.go b/pkg/plugin-conformance-tests/runner_test.go index 2b6d78fe0..c05641ec6 100644 --- a/pkg/plugin-conformance-tests/runner_test.go +++ b/pkg/plugin-conformance-tests/runner_test.go @@ -887,7 +887,7 @@ func TestIsProviderDefault(t *testing.T) { } }) - t.Run("collection provider defaults - key patterns", func(t *testing.T) { + t.Run("collection provider defaults - simple key patterns", func(t *testing.T) { providerDefaults := map[string]providerDefault{ "metadata.labels": { IsCollection: true, @@ -899,9 +899,11 @@ func TestIsProviderDefault(t *testing.T) { path string expected bool }{ - // Matching patterns + // Matching patterns (first segment match) {"metadata.labels.app", true}, {"metadata.labels.helm", true}, + // Dotted key — first segment "app" matches pattern "app" + {"metadata.labels.app.kubernetes.io/name", true}, // Non-matching patterns {"metadata.labels.env", false}, {"metadata.labels.random", false}, @@ -916,6 +918,35 @@ func TestIsProviderDefault(t *testing.T) { } }) + t.Run("collection provider defaults - dotted key patterns", func(t *testing.T) { + providerDefaults := map[string]providerDefault{ + "metadata.labels": { + IsCollection: true, + KeyPatterns: []string{"app.kubernetes.io/*", "helm.sh/*"}, + }, + } + + tests := []struct { + path string + expected bool + }{ + // Full dotted key matches dotted pattern + {"metadata.labels.app.kubernetes.io/name", true}, + {"metadata.labels.app.kubernetes.io/instance", true}, + {"metadata.labels.helm.sh/chart", true}, + // Non-matching dotted patterns + {"metadata.labels.batch.kubernetes.io/job-name", false}, + // Simple key doesn't match dotted pattern + {"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}, From 10729152ff97613da0ea91dcdde075978b7da30c Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:24:14 +0100 Subject: [PATCH 3/6] fix(conformance): detect collection types from schema instead of properties Use schema Fields to detect Mapping fields instead of inspecting actual property values. A hasProviderDefault field with no child fields in the schema is a leaf field (Mapping), not a SubResource. This is more reliable than checking runtime property values which may vary. --- pkg/plugin-conformance-tests/runner.go | 59 ++++++++++++++------------ 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/pkg/plugin-conformance-tests/runner.go b/pkg/plugin-conformance-tests/runner.go index 9968ee5ed..7a01035e4 100644 --- a/pkg/plugin-conformance-tests/runner.go +++ b/pkg/plugin-conformance-tests/runner.go @@ -420,39 +420,46 @@ func extractProviderDefaultPaths(resource map[string]any) map[string]providerDef } } - // Detect collection fields by checking if the actual properties at this path - // contain a map value. This enables broad tolerance (Phase 1) for Mapping fields - // with hasProviderDefault=true even without explicit key patterns. - if props, ok := resource["Properties"].(map[string]any); ok { - for path, pd := range result { - if pd.IsCollection { - continue // already marked via key patterns - } - if isMapAtPath(props, path) { - pd.IsCollection = true - 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. If it's an object type (not a scalar), it's a Mapping + // where the provider may add extra keys. We detect this by checking if + // any other schema field starts with this path as a prefix — if not, + // it's a leaf (Mapping or scalar). We then check the actual properties + // to confirm it's a map value. + 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 // already marked via key patterns + } + // Check if this field has any child fields in the schema. + // If it does, it's a SubResource (not a Mapping) — skip. + hasChildren := false + prefix := path + "." + for field := range fieldSet { + if strings.HasPrefix(field, prefix) { + hasChildren = true + break } } + if !hasChildren { + // Leaf field with hasProviderDefault — treat as collection (Mapping). + // This covers fields like labels: Mapping where the + // schema defines the field but not its dynamic keys. + pd.IsCollection = true + result[path] = pd + } } return result } -// isMapAtPath checks if the value at a dot-separated path in a nested map is itself a map. -func isMapAtPath(obj map[string]any, path string) bool { - parts := strings.Split(path, ".") - current := any(obj) - for _, part := range parts { - m, ok := current.(map[string]any) - if !ok { - return false - } - current = m[part] - } - _, isMap := current.(map[string]any) - return isMap -} - // isProviderDefault checks if a given field path (dot-separated) matches // a provider default path. Handles: // - Exact match for scalar fields From f7fd498184f60eda164b33317c80758f4a05ae63 Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:26:59 +0100 Subject: [PATCH 4/6] fix: update PKL expected output for ProviderDefaultKeyPatterns field --- .../pkl/schema/tests/formae.pkl-expected.pcf | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/internal/schema/pkl/schema/tests/formae.pkl-expected.pcf b/internal/schema/pkl/schema/tests/formae.pkl-expected.pcf index 1e97d9632..3708d3343 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 = "" } @@ -192,6 +198,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -201,6 +208,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -210,6 +218,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -265,6 +274,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -274,6 +284,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -283,6 +294,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -323,6 +335,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -332,6 +345,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -341,6 +355,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -350,6 +365,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -359,6 +375,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -368,6 +385,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -377,6 +395,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -412,6 +431,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -421,6 +441,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -430,6 +451,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -450,6 +472,7 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -459,6 +482,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -468,6 +492,7 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false + ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } From 6fad2f5a874bad4650abe9a7695efbcf411562f0 Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:28:01 +0100 Subject: [PATCH 5/6] refactor: remove ProviderDefaultKeyPatterns from hasProviderDefault Simplify collection-aware hasProviderDefault to broad tolerance only. Key pattern filtering (providerDefaultKeyPatterns) is removed from this PR and will be proposed as a separate feature. The core behavior remains: Mapping fields with hasProviderDefault=true tolerate all extra keys via prefix matching. Collection detection uses the schema structure (leaf fields with no children). --- internal/schema/pkl/schema/formae.pkl | 8 --- .../pkl/schema/tests/formae.pkl-expected.pcf | 25 ------- pkg/model/schema.go | 6 -- pkg/plugin-conformance-tests/runner.go | 66 +++---------------- pkg/plugin-conformance-tests/runner_test.go | 60 ----------------- 5 files changed, 8 insertions(+), 157 deletions(-) diff --git a/internal/schema/pkl/schema/formae.pkl b/internal/schema/pkl/schema/formae.pkl index b7692eea0..3d823e4ab 100644 --- a/internal/schema/pkl/schema/formae.pkl +++ b/internal/schema/pkl/schema/formae.pkl @@ -179,13 +179,6 @@ open class FieldHint extends Annotation { hidden indexField: String? hidden updateMethod: FieldUpdateMethod? - /// 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., "app.kubernetes.io/*"). - hidden providerDefaultKeyPatterns: Listing? - hidden outputField: String? hidden outputTransformation: ((Any) -> Any)? hidden outputCondition: ((Any) -> Boolean) = (_) -> true @@ -196,7 +189,6 @@ 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 b6354514f..176e2dcf4 100644 --- a/internal/schema/pkl/schema/tests/formae.pkl-expected.pcf +++ b/internal/schema/pkl/schema/tests/formae.pkl-expected.pcf @@ -89,7 +89,6 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -99,7 +98,6 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -109,7 +107,6 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -130,7 +127,6 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -140,7 +136,6 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -150,7 +145,6 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -199,7 +193,6 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -209,7 +202,6 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -219,7 +211,6 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -276,7 +267,6 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -286,7 +276,6 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -296,7 +285,6 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -338,7 +326,6 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -348,7 +335,6 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -358,7 +344,6 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -368,7 +353,6 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -378,7 +362,6 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -388,7 +371,6 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -398,7 +380,6 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -435,7 +416,6 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -445,7 +425,6 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -455,7 +434,6 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -476,7 +454,6 @@ examples { Required = true RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -486,7 +463,6 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } @@ -496,7 +472,6 @@ examples { Required = false RequiredOnCreate = false HasProviderDefault = false - ProviderDefaultKeyPatterns = null UpdateMethod = "" IndexField = "" } diff --git a/pkg/model/schema.go b/pkg/model/schema.go index 6ad9b034e..e0ea0f3e3 100644 --- a/pkg/model/schema.go +++ b/pkg/model/schema.go @@ -20,12 +20,6 @@ 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 7a01035e4..82d3d5eac 100644 --- a/pkg/plugin-conformance-tests/runner.go +++ b/pkg/plugin-conformance-tests/runner.go @@ -374,20 +374,15 @@ 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"). +// 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 -// and optional key patterns for fine-grained control. +// 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. - KeyPatterns []string } func extractProviderDefaultPaths(resource map[string]any) map[string]providerDefault { @@ -406,27 +401,13 @@ func extractProviderDefaultPaths(resource map[string]any) map[string]providerDef continue } if hpd, ok := hintMap["HasProviderDefault"].(bool); ok && hpd { - pd := providerDefault{} - // Check if this field has key patterns (indicates a collection/Mapping field) - 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 + result[path] = providerDefault{} } } // Detect collection (Mapping) fields from the schema structure. // A field with hasProviderDefault that has no child fields in the schema - // is a leaf field. If it's an object type (not a scalar), it's a Mapping - // where the provider may add extra keys. We detect this by checking if - // any other schema field starts with this path as a prefix — if not, - // it's a leaf (Mapping or scalar). We then check the actual properties - // to confirm it's a map value. + // 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 { @@ -436,10 +417,8 @@ func extractProviderDefaultPaths(resource map[string]any) map[string]providerDef } for path, pd := range result { if pd.IsCollection { - continue // already marked via key patterns + continue } - // Check if this field has any child fields in the schema. - // If it does, it's a SubResource (not a Mapping) — skip. hasChildren := false prefix := path + "." for field := range fieldSet { @@ -449,9 +428,6 @@ func extractProviderDefaultPaths(resource map[string]any) map[string]providerDef } } if !hasChildren { - // Leaf field with hasProviderDefault — treat as collection (Mapping). - // This covers fields like labels: Mapping where the - // schema defines the field but not its dynamic keys. pd.IsCollection = true result[path] = pd } @@ -465,7 +441,6 @@ func extractProviderDefaultPaths(resource map[string]any) map[string]providerDef // - 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 { @@ -474,33 +449,8 @@ func isProviderDefault(fieldPath string, providerDefaults map[string]providerDef // 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. - // The remainder may contain dots from dot-expansion of the original - // JSON key (e.g., "app.kubernetes.io/name" → remainder "app.kubernetes.io/name"). - // We try matching both the full remainder and the first segment to - // support both dotted patterns ("app.kubernetes.io/*") and simple - // patterns ("app"). - remainder := fieldPath[len(prefix):] - for _, pattern := range pd.KeyPatterns { - // Match against full remainder (supports dotted patterns like "app.kubernetes.io/*") - if matched, _ := filepath.Match(pattern, remainder); matched { - return true - } - // Match against first segment (supports simple patterns like "app") - firstSegment := strings.SplitN(remainder, ".", 2)[0] - if matched, _ := filepath.Match(pattern, firstSegment); matched { - return true - } - } - return false + if pd.IsCollection && strings.HasPrefix(fieldPath, path+".") { + return true } } diff --git a/pkg/plugin-conformance-tests/runner_test.go b/pkg/plugin-conformance-tests/runner_test.go index c05641ec6..c3e943f7e 100644 --- a/pkg/plugin-conformance-tests/runner_test.go +++ b/pkg/plugin-conformance-tests/runner_test.go @@ -887,66 +887,6 @@ func TestIsProviderDefault(t *testing.T) { } }) - 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 - }{ - // Matching patterns (first segment match) - {"metadata.labels.app", true}, - {"metadata.labels.helm", true}, - // Dotted key — first segment "app" matches pattern "app" - {"metadata.labels.app.kubernetes.io/name", true}, - // Non-matching patterns - {"metadata.labels.env", false}, - {"metadata.labels.random", false}, - // Exact match on the collection itself - {"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{"app.kubernetes.io/*", "helm.sh/*"}, - }, - } - - tests := []struct { - path string - expected bool - }{ - // Full dotted key matches dotted pattern - {"metadata.labels.app.kubernetes.io/name", true}, - {"metadata.labels.app.kubernetes.io/instance", true}, - {"metadata.labels.helm.sh/chart", true}, - // Non-matching dotted patterns - {"metadata.labels.batch.kubernetes.io/job-name", false}, - // Simple key doesn't match dotted pattern - {"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}, From fa91dc19f31012b374d6fbb85480465c1a2844e7 Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:39:09 +0100 Subject: [PATCH 6/6] feat(conformance): add providerDefaultKeyPatterns for fine-grained Mapping tolerance Add optional glob pattern filtering to collection-aware hasProviderDefault. When providerDefaultKeyPatterns is specified on a Mapping field's FieldHint, only extra keys matching at least one pattern are tolerated. When empty, all extra keys are tolerated (existing broad tolerance behavior). Patterns match against both the full remainder after the Mapping path (for dotted patterns like "provider.example.com/*") and the first segment (for simple patterns like "app"). Changes: - Add providerDefaultKeyPatterns to FieldHint (PKL + Go) - Add KeyPatterns to providerDefault struct in conformance runner - Extract patterns from schema hints during provider default detection - Add pattern matching logic in isProviderDefault() - Add tests for simple and dotted key patterns - Update PKL expected output --- internal/schema/pkl/schema/formae.pkl | 9 ++++ .../pkl/schema/tests/formae.pkl-expected.pcf | 25 +++++++++ pkg/model/schema.go | 6 +++ pkg/plugin-conformance-tests/runner.go | 40 ++++++++++++-- pkg/plugin-conformance-tests/runner_test.go | 53 +++++++++++++++++++ 5 files changed, 130 insertions(+), 3 deletions(-) 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 82d3d5eac..5bcae2c0a 100644 --- a/pkg/plugin-conformance-tests/runner.go +++ b/pkg/plugin-conformance-tests/runner.go @@ -383,6 +383,10 @@ type providerDefault struct { // 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 { @@ -401,7 +405,16 @@ func extractProviderDefaultPaths(resource map[string]any) map[string]providerDef continue } if hpd, ok := hintMap["HasProviderDefault"].(bool); ok && hpd { - result[path] = providerDefault{} + 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 } } @@ -441,6 +454,7 @@ func extractProviderDefaultPaths(resource map[string]any) map[string]providerDef // - 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 { @@ -449,8 +463,28 @@ func isProviderDefault(fieldPath string, providerDefaults map[string]providerDef // Prefix match for collection (Mapping) provider defaults for path, pd := range providerDefaults { - if pd.IsCollection && strings.HasPrefix(fieldPath, path+".") { - return true + 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 } } diff --git a/pkg/plugin-conformance-tests/runner_test.go b/pkg/plugin-conformance-tests/runner_test.go index c3e943f7e..5500b66d2 100644 --- a/pkg/plugin-conformance-tests/runner_test.go +++ b/pkg/plugin-conformance-tests/runner_test.go @@ -887,6 +887,59 @@ func TestIsProviderDefault(t *testing.T) { } }) + 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},