diff --git a/acceptance/bundle/null/map-key/databricks.yml b/acceptance/bundle/null/map-key/databricks.yml new file mode 100644 index 00000000000..a937b96f7c0 --- /dev/null +++ b/acceptance/bundle/null/map-key/databricks.yml @@ -0,0 +1,43 @@ +bundle: + name: test-bundle + +run_as: + user_name: base-user + +variables: + foo: + default: base-value + description: a variable + +resources: + jobs: + my_job: + name: my job + description: base description + +targets: + dev-run-as: + # Complex: null on a struct-typed field at the top level of a target override. + # The base run_as value survives merge. When null-merge semantics change this + # field should become absent. + run_as: + + dev-job-field: + # Scalar: null on a string field inside a resource. Normalization drops it + # and emits a warning; the base description survives merge. + resources: + jobs: + my_job: + description: + + dev-variable: + # MapKey: null on a value in the variables map (map[string]*TargetVariable). + # The null overrides the variable entry; currently survives as null. + variables: + foo: + + dev-resource: + # Resource: null on an entire resource entry. The job is completely nulled. + resources: + jobs: + my_job: diff --git a/acceptance/bundle/null/map-key/out.test.toml b/acceptance/bundle/null/map-key/out.test.toml new file mode 100644 index 00000000000..d6187dcb046 --- /dev/null +++ b/acceptance/bundle/null/map-key/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] diff --git a/acceptance/bundle/null/map-key/output.txt b/acceptance/bundle/null/map-key/output.txt new file mode 100644 index 00000000000..3997bf6b69c --- /dev/null +++ b/acceptance/bundle/null/map-key/output.txt @@ -0,0 +1,81 @@ + +>>> [CLI] bundle validate -o json -t dev-run-as +Warning: expected a string value, found null + at targets.dev-job-field.resources.jobs.my_job.description + in databricks.yml:31:23 + +{ + "run_as": { + "user_name": "base-user" + } +} + +>>> [CLI] bundle validate -o json -t dev-job-field +Warning: expected a string value, found null + at targets.dev-job-field.resources.jobs.my_job.description + in databricks.yml:31:23 + +{ + "description": "base description" +} + +>>> [CLI] bundle validate -o json -t dev-variable +Warning: expected a string value, found null + at targets.dev-job-field.resources.jobs.my_job.description + in databricks.yml:31:23 + +{ + "variables": { + "foo": { + "default": "base-value", + "description": "a variable", + "value": "base-value" + } + } +} + +>>> [CLI] bundle validate -o json -t dev-resource +Warning: expected a string value, found null + at targets.dev-job-field.resources.jobs.my_job.description + in databricks.yml:31:23 + +{ + "resources": { + "jobs": { + "my_job": { + "deployment": { + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/test-bundle/dev-resource/state/metadata.json" + }, + "description": "base description", + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "my job", + "queue": { + "enabled": true + }, + "run_as": { + "user_name": "base-user" + } + } + } + } +} + +>>> [CLI] bundle deploy -t dev-run-as +Warning: expected a string value, found null + at targets.dev-job-field.resources.jobs.my_job.description + in databricks.yml:31:23 + +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/dev-run-as/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_telemetry_bool_values +null-in-targets true +null-in-targets-complex true +null-in-targets-key true +null-in-targets-resource true +null-in-targets-scalar true diff --git a/acceptance/bundle/null/map-key/script b/acceptance/bundle/null/map-key/script new file mode 100644 index 00000000000..65527669153 --- /dev/null +++ b/acceptance/bundle/null/map-key/script @@ -0,0 +1,9 @@ +trace $CLI bundle validate -o json -t dev-run-as | jq '{run_as: .run_as}' +trace $CLI bundle validate -o json -t dev-job-field | jq '{description: .resources.jobs.my_job.description}' +trace $CLI bundle validate -o json -t dev-variable | jq '{variables: {foo: .variables.foo}}' +trace $CLI bundle validate -o json -t dev-resource | jq '{resources: {jobs: .resources.jobs}}' + +trace $CLI bundle deploy -t dev-run-as +trace print_telemetry_bool_values | grep null-in-targets + +rm -f out.requests.txt diff --git a/acceptance/bundle/null/null-from-include/databricks.yml b/acceptance/bundle/null/null-from-include/databricks.yml new file mode 100644 index 00000000000..e20950f81c7 --- /dev/null +++ b/acceptance/bundle/null/null-from-include/databricks.yml @@ -0,0 +1,17 @@ +bundle: + name: test-bundle + +include: + - override.yml + +workspace: + root_path: /Workspace/Users/base-root + +targets: + dev: + default: true + +resources: + jobs: + my_job: + name: my job diff --git a/acceptance/bundle/null/null-from-include/out.test.toml b/acceptance/bundle/null/null-from-include/out.test.toml new file mode 100644 index 00000000000..d6187dcb046 --- /dev/null +++ b/acceptance/bundle/null/null-from-include/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] diff --git a/acceptance/bundle/null/null-from-include/output.txt b/acceptance/bundle/null/null-from-include/output.txt new file mode 100644 index 00000000000..996420de4e2 --- /dev/null +++ b/acceptance/bundle/null/null-from-include/output.txt @@ -0,0 +1,25 @@ + +>>> [CLI] bundle validate -o json -t dev +Warning: expected a string value, found null + at targets.dev.workspace.root_path + in override.yml:6:17 + +{ + "workspace": { + "root_path": "/Workspace/Users/base-root" + } +} + +>>> [CLI] bundle deploy -t dev +Warning: expected a string value, found null + at targets.dev.workspace.root_path + in override.yml:6:17 + +Uploading bundle files to /Workspace/Users/base-root/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_telemetry_bool_values +null-in-targets true +null-in-targets-scalar true diff --git a/acceptance/bundle/null/null-from-include/override.yml b/acceptance/bundle/null/null-from-include/override.yml new file mode 100644 index 00000000000..0e7ac0a67ac --- /dev/null +++ b/acceptance/bundle/null/null-from-include/override.yml @@ -0,0 +1,6 @@ +targets: + dev: + # Null in an included file under targets: NullInTargets should be true + # even though the null comes from an included file, not the root config. + workspace: + root_path: diff --git a/acceptance/bundle/null/null-from-include/script b/acceptance/bundle/null/null-from-include/script new file mode 100644 index 00000000000..5747d8b32c7 --- /dev/null +++ b/acceptance/bundle/null/null-from-include/script @@ -0,0 +1,6 @@ +trace $CLI bundle validate -o json -t dev | jq '{workspace: {root_path: .workspace.root_path}}' + +trace $CLI bundle deploy -t dev +trace print_telemetry_bool_values | grep null-in-targets + +rm -f out.requests.txt diff --git a/acceptance/bundle/null/scalar-field/databricks.yml b/acceptance/bundle/null/scalar-field/databricks.yml new file mode 100644 index 00000000000..88a484124b9 --- /dev/null +++ b/acceptance/bundle/null/scalar-field/databricks.yml @@ -0,0 +1,27 @@ +bundle: + name: test-bundle + +workspace: + root_path: /Workspace/Users/base-root + +targets: + dev-null: + workspace: + # YAML null: normalization drops the null, so the base root_path survives + # in the merged config. When null-merge semantics change, this field should + # become absent (reverting to the default ~/.bundle/... path). + root_path: + + dev-empty: + workspace: + # Explicit empty string: not a null, so normalization keeps it. + # The empty string overrides the base, then DefineDefaultWorkspaceRoot + # re-fills it with the default ~/.bundle/... path (since the field is ""). + # Empty string and YAML null currently produce the same final value via + # different paths; this is captured here to document both behaviors. + root_path: "" + +resources: + jobs: + my_job: + name: my job diff --git a/acceptance/bundle/null/scalar-field/out.test.toml b/acceptance/bundle/null/scalar-field/out.test.toml new file mode 100644 index 00000000000..d6187dcb046 --- /dev/null +++ b/acceptance/bundle/null/scalar-field/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] diff --git a/acceptance/bundle/null/scalar-field/output.txt b/acceptance/bundle/null/scalar-field/output.txt new file mode 100644 index 00000000000..9b56855783e --- /dev/null +++ b/acceptance/bundle/null/scalar-field/output.txt @@ -0,0 +1,36 @@ + +>>> [CLI] bundle validate -o json -t dev-null +Warning: expected a string value, found null + at targets.dev-null.workspace.root_path + in databricks.yml:13:17 + +{ + "workspace": { + "root_path": "/Workspace/Users/base-root" + } +} + +>>> [CLI] bundle validate -o json -t dev-empty +Warning: expected a string value, found null + at targets.dev-null.workspace.root_path + in databricks.yml:13:17 + +{ + "workspace": { + "root_path": "/Workspace/Users/base-root" + } +} + +>>> [CLI] bundle deploy -t dev-null +Warning: expected a string value, found null + at targets.dev-null.workspace.root_path + in databricks.yml:13:17 + +Uploading bundle files to /Workspace/Users/base-root/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_telemetry_bool_values +null-in-targets true +null-in-targets-scalar true diff --git a/acceptance/bundle/null/scalar-field/script b/acceptance/bundle/null/scalar-field/script new file mode 100644 index 00000000000..3bf52d36c68 --- /dev/null +++ b/acceptance/bundle/null/scalar-field/script @@ -0,0 +1,7 @@ +trace $CLI bundle validate -o json -t dev-null | jq '{workspace: {root_path: .workspace.root_path}}' +trace $CLI bundle validate -o json -t dev-empty | jq '{workspace: {root_path: .workspace.root_path}}' + +trace $CLI bundle deploy -t dev-null +trace print_telemetry_bool_values | grep null-in-targets + +rm -f out.requests.txt diff --git a/acceptance/bundle/null/test.toml b/acceptance/bundle/null/test.toml new file mode 100644 index 00000000000..9c21f98f793 --- /dev/null +++ b/acceptance/bundle/null/test.toml @@ -0,0 +1,5 @@ +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = [] +RecordRequests = true + +[Env] +DATABRICKS_CACHE_ENABLED = "false" diff --git a/acceptance/bundle/telemetry/deploy-app-lifecycle-started/output.txt b/acceptance/bundle/telemetry/deploy-app-lifecycle-started/output.txt index 3663f140db9..3fb5f331688 100644 --- a/acceptance/bundle/telemetry/deploy-app-lifecycle-started/output.txt +++ b/acceptance/bundle/telemetry/deploy-app-lifecycle-started/output.txt @@ -9,6 +9,10 @@ Deployment complete! >>> cat out.requests.txt { "bool_values": [ + { + "key": "null-in-targets", + "value": false + }, { "key": "local.cache.attempt", "value": true diff --git a/acceptance/bundle/telemetry/deploy-compute-type/output.txt b/acceptance/bundle/telemetry/deploy-compute-type/output.txt index 754948595ff..73928c8a088 100644 --- a/acceptance/bundle/telemetry/deploy-compute-type/output.txt +++ b/acceptance/bundle/telemetry/deploy-compute-type/output.txt @@ -34,6 +34,8 @@ local.cache.attempt true local.cache.attempt true local.cache.hit true local.cache.miss true +null-in-targets false +null-in-targets false permissions_section_set false permissions_section_set false presets_name_prefix_is_set false diff --git a/acceptance/bundle/telemetry/deploy-experimental/output.txt b/acceptance/bundle/telemetry/deploy-experimental/output.txt index e8c847cd617..5196863ec15 100644 --- a/acceptance/bundle/telemetry/deploy-experimental/output.txt +++ b/acceptance/bundle/telemetry/deploy-experimental/output.txt @@ -21,6 +21,7 @@ has_classic_job_compute false has_serverless_compute false local.cache.attempt true local.cache.miss true +null-in-targets false permissions_section_set false presets_name_prefix_is_set false python_wheel_wrapper_is_set false diff --git a/acceptance/bundle/telemetry/deploy-name-prefix/custom/output.txt b/acceptance/bundle/telemetry/deploy-name-prefix/custom/output.txt index 4f5630089f2..08dd225d829 100644 --- a/acceptance/bundle/telemetry/deploy-name-prefix/custom/output.txt +++ b/acceptance/bundle/telemetry/deploy-name-prefix/custom/output.txt @@ -17,6 +17,7 @@ has_classic_job_compute false has_serverless_compute false local.cache.attempt true local.cache.miss true +null-in-targets false permissions_section_set false presets_name_prefix_is_set true python_wheel_wrapper_is_set false diff --git a/acceptance/bundle/telemetry/deploy-name-prefix/mode-development/output.txt b/acceptance/bundle/telemetry/deploy-name-prefix/mode-development/output.txt index 3122ad1ee9a..bc5efa44507 100644 --- a/acceptance/bundle/telemetry/deploy-name-prefix/mode-development/output.txt +++ b/acceptance/bundle/telemetry/deploy-name-prefix/mode-development/output.txt @@ -17,6 +17,7 @@ has_classic_job_compute false has_serverless_compute false local.cache.attempt true local.cache.miss true +null-in-targets false permissions_section_set false presets_name_prefix_is_set true python_wheel_wrapper_is_set false diff --git a/acceptance/bundle/telemetry/deploy-whl-artifacts/output.txt b/acceptance/bundle/telemetry/deploy-whl-artifacts/output.txt index 95776306d7e..85262c767fa 100644 --- a/acceptance/bundle/telemetry/deploy-whl-artifacts/output.txt +++ b/acceptance/bundle/telemetry/deploy-whl-artifacts/output.txt @@ -39,6 +39,8 @@ local.cache.attempt true local.cache.attempt true local.cache.hit true local.cache.miss true +null-in-targets false +null-in-targets false permissions_section_set false permissions_section_set false python_wheel_wrapper_is_set false diff --git a/acceptance/bundle/telemetry/deploy/out.telemetry.txt b/acceptance/bundle/telemetry/deploy/out.telemetry.txt index 1a4b5cbb143..aab20a4bc45 100644 --- a/acceptance/bundle/telemetry/deploy/out.telemetry.txt +++ b/acceptance/bundle/telemetry/deploy/out.telemetry.txt @@ -42,6 +42,10 @@ "lookup_variable_count": 0, "target_count": 1, "bool_values": [ + { + "key": "null-in-targets", + "value": false + }, { "key": "local.cache.attempt", "value": true diff --git a/bundle/config/mutator/collect_null_telemetry.go b/bundle/config/mutator/collect_null_telemetry.go new file mode 100644 index 00000000000..0ecb39cbfa8 --- /dev/null +++ b/bundle/config/mutator/collect_null_telemetry.go @@ -0,0 +1,43 @@ +package mutator + +import ( + "context" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/libs/diag" +) + +// collectNullTelemetry records per-category flags for null values found anywhere +// under the "targets" section, as authored. The signal is computed at load time +// (before normalization drops nulls on scalar-typed fields) and accumulated across +// all included files; this mutator just surfaces it as telemetry. +type collectNullTelemetry struct{} + +func CollectNullTelemetry() bundle.Mutator { + return &collectNullTelemetry{} +} + +func (*collectNullTelemetry) Name() string { + return "CollectNullTelemetry" +} + +func (*collectNullTelemetry) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics { + info := b.Config.NullInTargets() + b.Metrics.SetBoolValue("null-in-targets", info.Any()) + if info.Scalar { + b.Metrics.SetBoolValue("null-in-targets-scalar", true) + } + if info.Complex { + b.Metrics.SetBoolValue("null-in-targets-complex", true) + } + if info.MapKey { + b.Metrics.SetBoolValue("null-in-targets-key", true) + } + if info.ArrayIndex { + b.Metrics.SetBoolValue("null-in-targets-index", true) + } + if info.Resource { + b.Metrics.SetBoolValue("null-in-targets-resource", true) + } + return nil +} diff --git a/bundle/config/root.go b/bundle/config/root.go index caca8e1f1ad..478e99672ad 100644 --- a/bundle/config/root.go +++ b/bundle/config/root.go @@ -30,6 +30,12 @@ type Root struct { //nolint:recvcheck // value receivers for read-only accessors value dyn.Value depth int + // nullInTargets records the categories of null values found anywhere under + // the "targets" section of this file (or any file merged into it). Computed + // before normalization so scalar-typed nulls (which normalization drops) are + // also detected. Surfaced as telemetry by CollectNullTelemetry. + nullInTargets NullInTargetsInfo + // Contains user defined variables Variables map[string]*variable.Variable `json:"variables,omitempty"` @@ -123,9 +129,16 @@ func LoadFromBytes(path string, raw []byte) (*Root, diag.Diagnostics) { return nil, diag.Errorf("failed to rewrite %s: %v", path, err) } + // Collect null categories before normalization (raw tree) and after (norm + // tree) so we can distinguish scalar nulls (dropped by normalization) from + // complex nulls (struct/map/seq typed fields that survive). + rawTargets := v.Get("targets") + // Normalize dynamic configuration tree according to configuration type. v, diags := convert.Normalize(r, v) + r.nullInTargets = collectNullsInTargets(rawTargets, v.Get("targets")) + // Convert normalized configuration tree to typed configuration. err = r.updateWithDynamicValue(v) if err != nil { @@ -135,6 +148,131 @@ func LoadFromBytes(path string, raw []byte) (*Root, diag.Diagnostics) { return &r, diags } +// NullInTargetsInfo categorises null values found under the "targets" section +// of a bundle config as authored (before normalization and target override merge). +// +// Each field is a "has at least one" flag. The categories are: +// - Scalar: null on a scalar-typed field (string/int/bool); normalization drops it +// and emits a warning — it never reaches the merge. +// - Complex: null on a struct-typed field (e.g. run_as:) at the top level of a +// target override (depth ≤ 2 inside the target); survives normalization. +// - MapKey: null as a value in a map[string]X typed field (e.g. variables.foo: +// or resources.jobs.foo.description:) at depth > 2; survives normalization. +// - ArrayIndex: null at a sequence index (e.g. a null item in a list). +// - Resource: null at exactly resources.. inside a target — the +// entire resource entry is nulled out. +type NullInTargetsInfo struct { + Scalar bool + Complex bool + MapKey bool + ArrayIndex bool + Resource bool +} + +// Any reports whether any null category was detected. +func (n NullInTargetsInfo) Any() bool { + return n.Scalar || n.Complex || n.MapKey || n.ArrayIndex || n.Resource +} + +// Merge combines two NullInTargetsInfo values with OR, used when merging +// included files into the root config. +func (n NullInTargetsInfo) Merge(other NullInTargetsInfo) NullInTargetsInfo { + return NullInTargetsInfo{ + Scalar: n.Scalar || other.Scalar, + Complex: n.Complex || other.Complex, + MapKey: n.MapKey || other.MapKey, + ArrayIndex: n.ArrayIndex || other.ArrayIndex, + Resource: n.Resource || other.Resource, + } +} + +// collectNullsInTargets walks the raw (pre-normalization) and normalized targets +// sections and classifies each null value into one of the NullInTargetsInfo +// categories. +// +// rawTargets is the targets value from the raw YAML tree (before normalization). +// normTargets is the targets value after normalization (nulls on scalar-typed +// fields have been removed; nulls on complex-typed fields survive). +func collectNullsInTargets(rawTargets, normTargets dyn.Value) NullInTargetsInfo { + if rawTargets.Kind() != dyn.KindMap { + return NullInTargetsInfo{} + } + + // Collect paths of nulls that survived normalization (complex-typed fields). + normNullPaths := make(map[string]bool) + if normTargets.Kind() == dyn.KindMap { + _ = dyn.WalkReadOnly(normTargets, func(p dyn.Path, v dyn.Value) error { + if v.Kind() == dyn.KindNil { + normNullPaths[p.String()] = true + return dyn.ErrSkip + } + return nil + }) + } + + var info NullInTargetsInfo + + // Walk the raw tree and classify each null by path context. + // p is relative to the targets value, so: + // p[0] = target name (e.g. "dev") + // p[1] = first field inside target (e.g. "run_as", "resources", "variables") + // p[2] = second field (e.g. resource type "jobs", or variable name "foo") + // p[3] = third field (e.g. resource name "my_job") + _ = dyn.WalkReadOnly(rawTargets, func(p dyn.Path, v dyn.Value) error { + if v.Kind() != dyn.KindNil { + return nil + } + + // Array index: last path component is a sequence index (Key() is empty for indices). + if len(p) > 0 && p[len(p)-1].Key() == "" { + info.ArrayIndex = true + return dyn.ErrSkip + } + + // Paths relative to the target name component (p[0]). + // len(p) == 1 means the entire target is null; handled as Complex below. + field1 := "" + if len(p) >= 2 { + field1 = p[1].Key() + } + + switch { + // Resource null: exactly .resources... + case field1 == "resources" && len(p) == 4: + info.Resource = true + + // Map-key null: a value in a user-keyed map field (variables or inside + // a resource entry). variables. is depth 2; inside-resource fields + // are depth > 3. + case field1 == "variables" && len(p) == 3, + field1 == "resources" && len(p) > 4: + if normNullPaths[p.String()] { + info.MapKey = true + } else { + info.Scalar = true + } + + // Everything else: distinguish scalar vs complex by normalization survival. + default: + if normNullPaths[p.String()] { + info.Complex = true + } else { + info.Scalar = true + } + } + + return dyn.ErrSkip + }) + + return info +} + +// NullInTargets returns categorised information about null values found under +// the "targets" section of this config as authored. +func (r *Root) NullInTargets() NullInTargetsInfo { + return r.nullInTargets +} + func (r *Root) initializeDynamicValue() error { // Many test cases initialize a config as a Go struct literal. // The value will be invalid and we need to populate it from the typed configuration. @@ -155,9 +293,11 @@ func (r *Root) updateWithDynamicValue(nv dyn.Value) error { // Hack: restore state; it may be cleared by [ToTyped] if // the configuration equals nil (happens in tests). depth := r.depth + nullInTargets := r.nullInTargets defer func() { r.depth = depth + r.nullInTargets = nullInTargets }() // Convert normalized configuration tree to typed configuration. @@ -288,6 +428,9 @@ func (r *Root) InitializeVariables(vars []string) error { } func (r *Root) Merge(other *Root) error { + // Carry over the null-in-targets signal from included files. + r.nullInTargets = r.nullInTargets.Merge(other.nullInTargets) + // Merge dynamic configuration values. return r.Mutate(func(root dyn.Value) (dyn.Value, error) { return merge.Merge(root, other.value) diff --git a/bundle/config/root_test.go b/bundle/config/root_test.go index 42fae49d98c..d50873045cf 100644 --- a/bundle/config/root_test.go +++ b/bundle/config/root_test.go @@ -170,6 +170,127 @@ func TestRootMergeTargetOverridesWithVariables(t *testing.T) { assert.Equal(t, "complex var", root.Variables["complex"].Description) } +func TestNullInTargets(t *testing.T) { + tests := []struct { + name string + yaml string + expected NullInTargetsInfo + }{ + { + name: "no null under targets", + yaml: ` +targets: + dev: + workspace: + host: https://example.test +`, + expected: NullInTargetsInfo{}, + }, + { + name: "scalar null: workspace.host", + yaml: ` +targets: + dev: + workspace: + host: +`, + expected: NullInTargetsInfo{Scalar: true}, + }, + { + name: "complex null: run_as at top level of target", + yaml: ` +targets: + dev: + run_as: +`, + expected: NullInTargetsInfo{Complex: true}, + }, + { + name: "resource null: resources.jobs.foo", + yaml: ` +targets: + dev: + resources: + jobs: + foo: +`, + expected: NullInTargetsInfo{Resource: true}, + }, + { + name: "scalar null inside resource: resources.jobs.foo.description", + yaml: ` +targets: + dev: + resources: + jobs: + foo: + description: +`, + expected: NullInTargetsInfo{Scalar: true}, + }, + { + name: "map-key null: variables.foo", + yaml: ` +targets: + dev: + variables: + foo: +`, + expected: NullInTargetsInfo{MapKey: true}, + }, + { + name: "null outside targets is ignored", + yaml: ` +targets: + dev: + workspace: + host: https://example.test +variables: + foo: +`, + expected: NullInTargetsInfo{}, + }, + { + name: "no targets section", + yaml: ` +bundle: + name: test +`, + expected: NullInTargetsInfo{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root, diags := LoadFromBytes("databricks.yml", []byte(tt.yaml)) + require.NoError(t, diags.Error()) + assert.Equal(t, tt.expected, root.NullInTargets()) + }) + } +} + +func TestNullInTargetsAccumulatesOnMerge(t *testing.T) { + main, diags := LoadFromBytes("databricks.yml", []byte(` +targets: + dev: + workspace: + host: https://example.test +`)) + require.NoError(t, diags.Error()) + require.Equal(t, NullInTargetsInfo{}, main.NullInTargets()) + + included, diags := LoadFromBytes("included.yml", []byte(` +targets: + dev: + run_as: +`)) + require.NoError(t, diags.Error()) + require.Equal(t, NullInTargetsInfo{Complex: true}, included.NullInTargets()) + + require.NoError(t, main.Merge(included)) + assert.Equal(t, NullInTargetsInfo{Complex: true}, main.NullInTargets()) +} + func TestIsFullVariableOverrideDef(t *testing.T) { testCases := []struct { value dyn.Value diff --git a/bundle/phases/initialize.go b/bundle/phases/initialize.go index 8f54c141f80..c7a905c7aa6 100644 --- a/bundle/phases/initialize.go +++ b/bundle/phases/initialize.go @@ -51,6 +51,10 @@ func Initialize(ctx context.Context, b *bundle.Bundle) { // Updates (typed) b.Config.{Sync,Include,Exclude} they set to be relative to SyncRootPath instead of bundle root mutator.SyncInferRoot(), + // Surface the null-in-targets signal computed at load time as telemetry. + // Must run before InitializeCache so it appears first in bool_values. + mutator.CollectNullTelemetry(), + // Updates (typed): b.Cache (initializes cache for API responses) // Initialize cache before any mutator that might need it mutator.InitializeCache(),