diff --git a/.nextchanges/bundles/suppress-anchor-container-warnings.md b/.nextchanges/bundles/suppress-anchor-container-warnings.md new file mode 100644 index 00000000000..f376f6da499 --- /dev/null +++ b/.nextchanges/bundles/suppress-anchor-container-warnings.md @@ -0,0 +1 @@ +* Do not emit "unknown field" warnings for YAML anchors grouped in a list or map, matching the existing suppression for standalone anchors ([#5975](https://github.com/databricks/cli/pull/5975)). diff --git a/acceptance/bundle/validate/anchor_containers/databricks.yml b/acceptance/bundle/validate/anchor_containers/databricks.yml new file mode 100644 index 00000000000..a82d4fe7202 --- /dev/null +++ b/acceptance/bundle/validate/anchor_containers/databricks.yml @@ -0,0 +1,48 @@ +bundle: + name: test-bundle + +# This test verifies that anchors grouped in a list or a map do not trigger +# "unknown field" warnings. The suppression recurses into containers whose +# elements are all anchors. A container that also holds a non-anchor element is +# not suppressed: it is reported as an unknown field, since it is more likely a +# misplaced configuration value than a block of reusable anchors. + +task_defaults: &task_defaults + max_retries: 3 + +# List of anchors. +parameter_items: + - &p_first + name: first + default: one + - &p_second + name: second + default: two + +# Map of anchors. +parameter_map: + entry: &p_map_entry + name: first + +# Container nested inside a container: a map whose value is a list of anchors. +nested_parameters: + group: + - &p_nested + name: nested + +# Mixed container: one anchor and one non-anchor element. This is reported as an +# unknown field because not all elements are anchor containers. +mixed_parameters: + - &p_mixed + name: mixed + - name: plain + +resources: + jobs: + my_job: + name: "test job with anchors" + tasks: + - task_key: "main" + <<: *task_defaults + notebook_task: + notebook_path: "/some/notebook" diff --git a/acceptance/bundle/validate/anchor_containers/out.test.toml b/acceptance/bundle/validate/anchor_containers/out.test.toml new file mode 100644 index 00000000000..67759662971 --- /dev/null +++ b/acceptance/bundle/validate/anchor_containers/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false +GOOSOnPR.darwin = false +GOOSOnPR.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/validate/anchor_containers/output.txt b/acceptance/bundle/validate/anchor_containers/output.txt new file mode 100644 index 00000000000..86ae905e85f --- /dev/null +++ b/acceptance/bundle/validate/anchor_containers/output.txt @@ -0,0 +1,12 @@ + +>>> [CLI] bundle validate +Warning: unknown field: mixed_parameters + in databricks.yml:35:1 + +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default + +Found 1 warning diff --git a/acceptance/bundle/validate/anchor_containers/script b/acceptance/bundle/validate/anchor_containers/script new file mode 100644 index 00000000000..5350876150f --- /dev/null +++ b/acceptance/bundle/validate/anchor_containers/script @@ -0,0 +1 @@ +trace $CLI bundle validate diff --git a/libs/dyn/convert/normalize.go b/libs/dyn/convert/normalize.go index 79cfee37441..b6a5fffd07e 100644 --- a/libs/dyn/convert/normalize.go +++ b/libs/dyn/convert/normalize.go @@ -87,6 +87,36 @@ func typeMismatch(expected dyn.Kind, src dyn.Value, path dyn.Path) diag.Diagnost } } +// isAnchorContainer reports whether v is a YAML anchor or a non-empty +// sequence/map composed entirely of anchor containers. Anchors define reusable +// blocks and must not trigger "unknown field" warnings, including when nested +// inside a container. +func isAnchorContainer(v dyn.Value) bool { + if v.IsAnchor() { + return true + } + + var elements []dyn.Value + switch v.Kind() { + case dyn.KindSequence: + elements = v.MustSequence() + case dyn.KindMap: + elements = v.MustMap().Values() + default: + return false + } + + if len(elements) == 0 { + return false + } + for _, e := range elements { + if !isAnchorContainer(e) { + return false + } + } + return true +} + func (n normalizeOptions) normalizeStruct(typ reflect.Type, src dyn.Value, seen []reflect.Type, path dyn.Path) (dyn.Value, diag.Diagnostics) { var diags diag.Diagnostics @@ -101,7 +131,7 @@ func (n normalizeOptions) normalizeStruct(typ reflect.Type, src dyn.Value, seen fieldName := pk.MustString() index, ok := info.Fields[fieldName] if !ok { - if !pv.IsAnchor() { + if !isAnchorContainer(pv) { // Special case: provide a more helpful message for "valueFrom" vs "value_from" if fieldName == "valueFrom" { if _, hasValueFrom := info.Fields["value_from"]; hasValueFrom { diff --git a/libs/dyn/convert/normalize_test.go b/libs/dyn/convert/normalize_test.go index cf8ca061733..1cde66473b2 100644 --- a/libs/dyn/convert/normalize_test.go +++ b/libs/dyn/convert/normalize_test.go @@ -860,6 +860,79 @@ func TestNormalizeAnchors(t *testing.T) { }, vout.AsAny()) } +func TestNormalizeAnchorContainers(t *testing.T) { + type Tmp struct { + Foo string `json:"foo"` + } + + anchor := func() dyn.Value { + return dyn.V(map[string]dyn.Value{"name": dyn.V("x")}).MarkAnchor() + } + + tcases := []struct { + name string + value dyn.Value + wantWarn bool + }{ + { + name: "list of anchors", + value: dyn.V([]dyn.Value{anchor(), anchor()}), + wantWarn: false, + }, + { + name: "map of anchors", + value: dyn.V(map[string]dyn.Value{ + "a": anchor(), + "b": anchor(), + }), + wantWarn: false, + }, + { + name: "nested list of anchors", + value: dyn.V([]dyn.Value{dyn.V([]dyn.Value{anchor()})}), + wantWarn: false, + }, + { + name: "list with a non-anchor element", + value: dyn.V([]dyn.Value{anchor(), dyn.V(map[string]dyn.Value{"name": dyn.V("x")})}), + wantWarn: true, + }, + { + name: "empty list", + value: dyn.V([]dyn.Value{}), + wantWarn: true, + }, + { + name: "empty map", + value: dyn.V(map[string]dyn.Value{}), + wantWarn: true, + }, + } + + for _, tc := range tcases { + t.Run(tc.name, func(t *testing.T) { + var typ Tmp + vin := dyn.V(map[string]dyn.Value{ + "foo": dyn.V("bar"), + "thing": tc.value, + }) + + vout, diags := Normalize(typ, vin) + if tc.wantWarn { + assert.Len(t, diags, 1) + assert.Equal(t, "unknown field: thing", diags[0].Summary) + } else { + assert.Empty(t, diags) + } + + // The unknown field is never retained regardless of the warning. + assert.Equal(t, map[string]any{ + "foo": "bar", + }, vout.AsAny()) + }) + } +} + func TestNormalizeAnyFromSlice(t *testing.T) { var typ any v1 := dyn.NewValue(1, []dyn.Location{{File: "file", Line: 1, Column: 1}})