From 5dd87bcf84d988d7e3e0fbe6e6b0a5b57af7244a Mon Sep 17 00:00:00 2001 From: Yuri Barreira Date: Mon, 19 Oct 2026 11:06:14 -0300 Subject: [PATCH 1/4] Require condition on multi-table update triggers at validate time Bundle validate passed jobs the Jobs API rejects at deploy. Fixes #7101. --- bundle/config/validate/fast_validate.go | 1 + .../validate/job_table_update_condition.go | 52 +++++++++++ .../job_table_update_condition_test.go | 93 +++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 bundle/config/validate/job_table_update_condition.go create mode 100644 bundle/config/validate/job_table_update_condition_test.go diff --git a/bundle/config/validate/fast_validate.go b/bundle/config/validate/fast_validate.go index d01eb8c1491..e7bbb397395 100644 --- a/bundle/config/validate/fast_validate.go +++ b/bundle/config/validate/fast_validate.go @@ -29,6 +29,7 @@ func (f *fastValidate) Apply(ctx context.Context, rb *bundle.Bundle) diag.Diagno // Fast mutators with only in-memory checks JobClusterKeyDefined(), JobTaskClusterSpec(), + JobTableUpdateCondition(), // Blocking mutators. Deployments will fail if these checks fail. ValidateArtifactPath(), diff --git a/bundle/config/validate/job_table_update_condition.go b/bundle/config/validate/job_table_update_condition.go new file mode 100644 index 00000000000..6ff0c689e29 --- /dev/null +++ b/bundle/config/validate/job_table_update_condition.go @@ -0,0 +1,52 @@ +package validate + +import ( + "context" + "maps" + "slices" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/libs/diag" + "github.com/databricks/cli/libs/structs/structpath" +) + +func JobTableUpdateCondition() bundle.ReadOnlyMutator { + return &jobTableUpdateCondition{} +} + +type jobTableUpdateCondition struct{ bundle.RO } + +func (v *jobTableUpdateCondition) Name() string { + return "validate:job_table_update_condition" +} + +func (v *jobTableUpdateCondition) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnostics { + var diags diag.Diagnostics + + // Map iteration order is randomized; sort by name for stable output. + for _, name := range slices.Sorted(maps.Keys(b.Config.Resources.Jobs)) { + job := b.Config.Resources.Jobs[name] + // An empty `jobs.:` entry loads as a present key with a nil value. + if job == nil || job.Trigger == nil || job.Trigger.TableUpdate == nil { + continue + } + tableUpdate := job.Trigger.TableUpdate + // The Jobs API rejects the trigger when more than one table is listed + // without a condition, so fail validation instead of failing at deploy. + if len(tableUpdate.TableNames) <= 1 || tableUpdate.Condition != "" { + continue + } + // The condition field is missing from configuration, so point at the + // table_update block that holds a location. + path := structpath.NewPath(nil, "resources", "jobs", name, "trigger", "table_update") + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "trigger.table_update.condition is required when table_names lists more than one table", + Detail: "Set condition to ANY_UPDATED or ALL_UPDATED", + Paths: []*structpath.PathNode{path}, + Locations: b.Config.GetLocationsOf(path), + }) + } + + return diags +} diff --git a/bundle/config/validate/job_table_update_condition_test.go b/bundle/config/validate/job_table_update_condition_test.go new file mode 100644 index 00000000000..b3cae91821b --- /dev/null +++ b/bundle/config/validate/job_table_update_condition_test.go @@ -0,0 +1,93 @@ +package validate + +import ( + "testing" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/libs/diag" + "github.com/databricks/databricks-sdk-go/service/jobs" + "github.com/stretchr/testify/require" +) + +func tableUpdateJob(condition jobs.Condition, tables ...string) *resources.Job { + return &resources.Job{ + Name: "job1", + Trigger: &jobs.TriggerSettings{ + TableUpdate: &jobs.TableUpdateTriggerConfiguration{ + Condition: condition, + TableNames: tables, + }, + }, + } +} + +func TestJobTableUpdateConditionMissing(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Jobs: map[string]*resources.Job{ + "job1": tableUpdateJob("", "main.default.table_a", "main.default.table_b"), + }, + }, + }, + } + + diags := JobTableUpdateCondition().Apply(t.Context(), b) + require.Len(t, diags, 1) + require.Equal(t, diag.Error, diags[0].Severity) + require.Error(t, diags.Error()) + require.Equal(t, "trigger.table_update.condition is required when table_names lists more than one table", diags[0].Summary) + require.Len(t, diags[0].Paths, 1) + require.Equal(t, "resources.jobs.job1.trigger.table_update", diags[0].Paths[0].String()) +} + +func TestJobTableUpdateConditionSet(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Jobs: map[string]*resources.Job{ + "job1": tableUpdateJob(jobs.ConditionAnyUpdated, "main.default.table_a", "main.default.table_b"), + }, + }, + }, + } + + diags := JobTableUpdateCondition().Apply(t.Context(), b) + require.Empty(t, diags) + require.NoError(t, diags.Error()) +} + +func TestJobTableUpdateConditionSingleTable(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Jobs: map[string]*resources.Job{ + "job1": tableUpdateJob("", "main.default.table_a"), + }, + }, + }, + } + + diags := JobTableUpdateCondition().Apply(t.Context(), b) + require.Empty(t, diags) + require.NoError(t, diags.Error()) +} + +func TestJobTableUpdateConditionNoTrigger(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Jobs: map[string]*resources.Job{ + "job1": {JobSettings: jobs.JobSettings{Name: "job1"}}, + "job2": nil, + }, + }, + }, + } + + diags := JobTableUpdateCondition().Apply(t.Context(), b) + require.Empty(t, diags) + require.NoError(t, diags.Error()) +} From 19ec2cc6697f4ea0e2f91532be975530dd50f9a3 Mon Sep 17 00:00:00 2001 From: Yuri Barreira Date: Mon, 19 Oct 2026 11:21:11 -0300 Subject: [PATCH 2/4] Add changelog fragment for table update trigger validation --- .nextchanges/bundles/7107.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nextchanges/bundles/7107.md diff --git a/.nextchanges/bundles/7107.md b/.nextchanges/bundles/7107.md new file mode 100644 index 00000000000..913e44d6270 --- /dev/null +++ b/.nextchanges/bundles/7107.md @@ -0,0 +1 @@ +* Bundle validation now fails when a job table-update trigger lists more than one table without setting condition, instead of failing later at deploy. ([#7107](https://github.com/databricks/cli/pull/7107)) From 663c8e90c8ae73eb53865b77b19b528cd46d864a Mon Sep 17 00:00:00 2001 From: Yuri Barreira Date: Mon, 19 Oct 2026 17:01:09 -0300 Subject: [PATCH 3/4] Cover plural triggers in table update condition validation The Jobs API applies the same condition requirement to each entry of the triggers list, so check those entries alongside the singular trigger. Add tests for the plural path and the ALL_UPDATED value. --- .../validate/job_table_update_condition.go | 47 +++++++----- .../job_table_update_condition_test.go | 72 +++++++++++++++++++ 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/bundle/config/validate/job_table_update_condition.go b/bundle/config/validate/job_table_update_condition.go index 6ff0c689e29..b230c928cf0 100644 --- a/bundle/config/validate/job_table_update_condition.go +++ b/bundle/config/validate/job_table_update_condition.go @@ -8,6 +8,7 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/libs/diag" "github.com/databricks/cli/libs/structs/structpath" + "github.com/databricks/databricks-sdk-go/service/jobs" ) func JobTableUpdateCondition() bundle.ReadOnlyMutator { @@ -27,26 +28,40 @@ func (v *jobTableUpdateCondition) Apply(_ context.Context, b *bundle.Bundle) dia for _, name := range slices.Sorted(maps.Keys(b.Config.Resources.Jobs)) { job := b.Config.Resources.Jobs[name] // An empty `jobs.:` entry loads as a present key with a nil value. - if job == nil || job.Trigger == nil || job.Trigger.TableUpdate == nil { + if job == nil { continue } - tableUpdate := job.Trigger.TableUpdate - // The Jobs API rejects the trigger when more than one table is listed - // without a condition, so fail validation instead of failing at deploy. - if len(tableUpdate.TableNames) <= 1 || tableUpdate.Condition != "" { - continue + if job.Trigger != nil && job.Trigger.TableUpdate != nil { + path := structpath.NewPath(nil, "resources", "jobs", name, "trigger", "table_update") + diags = diags.Extend(checkTableUpdateCondition(b, job.Trigger.TableUpdate, path)) + } + // The Jobs API applies the same condition requirement to each entry + // of the plural `triggers` list. + for i := range job.Triggers { + if job.Triggers[i].TableUpdate == nil { + continue + } + path := structpath.NewPath(nil, "resources", "jobs", name, "triggers", i, "table_update") + diags = diags.Extend(checkTableUpdateCondition(b, job.Triggers[i].TableUpdate, path)) } - // The condition field is missing from configuration, so point at the - // table_update block that holds a location. - path := structpath.NewPath(nil, "resources", "jobs", name, "trigger", "table_update") - diags = append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "trigger.table_update.condition is required when table_names lists more than one table", - Detail: "Set condition to ANY_UPDATED or ALL_UPDATED", - Paths: []*structpath.PathNode{path}, - Locations: b.Config.GetLocationsOf(path), - }) } return diags } + +func checkTableUpdateCondition(b *bundle.Bundle, tableUpdate *jobs.TableUpdateTriggerConfiguration, path *structpath.PathNode) diag.Diagnostics { + // The Jobs API rejects the trigger when more than one table is listed + // without a condition, so fail validation instead of failing at deploy. + if len(tableUpdate.TableNames) <= 1 || tableUpdate.Condition != "" { + return nil + } + // The condition field is missing from configuration, so point at the + // table_update block that holds a location. + return diag.Diagnostics{{ + Severity: diag.Error, + Summary: "trigger.table_update.condition is required when table_names lists more than one table", + Detail: "Set condition to ANY_UPDATED or ALL_UPDATED", + Paths: []*structpath.PathNode{path}, + Locations: b.Config.GetLocationsOf(path), + }} +} diff --git a/bundle/config/validate/job_table_update_condition_test.go b/bundle/config/validate/job_table_update_condition_test.go index b3cae91821b..747c63f17c4 100644 --- a/bundle/config/validate/job_table_update_condition_test.go +++ b/bundle/config/validate/job_table_update_condition_test.go @@ -75,6 +75,78 @@ func TestJobTableUpdateConditionSingleTable(t *testing.T) { require.NoError(t, diags.Error()) } +func TestJobTableUpdateConditionAllUpdated(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Jobs: map[string]*resources.Job{ + "job1": tableUpdateJob(jobs.ConditionAllUpdated, "main.default.table_a", "main.default.table_b"), + }, + }, + }, + } + + diags := JobTableUpdateCondition().Apply(t.Context(), b) + require.Empty(t, diags) + require.NoError(t, diags.Error()) +} + +func tableUpdateTriggersJob(entries ...jobs.TriggerConfiguration) *resources.Job { + return &resources.Job{ + Name: "job1", + Triggers: entries, + } +} + +func tableUpdateTriggerEntry(condition jobs.Condition, tables ...string) jobs.TriggerConfiguration { + return jobs.TriggerConfiguration{ + TableUpdate: &jobs.TableUpdateTriggerConfiguration{ + Condition: condition, + TableNames: tables, + }, + } +} + +func TestJobTableUpdateConditionPluralTriggersMissing(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Jobs: map[string]*resources.Job{ + "job1": tableUpdateTriggersJob( + tableUpdateTriggerEntry("", "main.default.table_a", "main.default.table_b"), + tableUpdateTriggerEntry(jobs.ConditionAnyUpdated, "main.default.table_a", "main.default.table_b"), + ), + }, + }, + }, + } + + diags := JobTableUpdateCondition().Apply(t.Context(), b) + require.Len(t, diags, 1) + require.Equal(t, diag.Error, diags[0].Severity) + require.Equal(t, "trigger.table_update.condition is required when table_names lists more than one table", diags[0].Summary) + require.Len(t, diags[0].Paths, 1) + require.Equal(t, "resources.jobs.job1.triggers[0].table_update", diags[0].Paths[0].String()) +} + +func TestJobTableUpdateConditionPluralTriggersSingleTable(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Jobs: map[string]*resources.Job{ + "job1": tableUpdateTriggersJob( + tableUpdateTriggerEntry("", "main.default.table_a"), + ), + }, + }, + }, + } + + diags := JobTableUpdateCondition().Apply(t.Context(), b) + require.Empty(t, diags) + require.NoError(t, diags.Error()) +} + func TestJobTableUpdateConditionNoTrigger(t *testing.T) { b := &bundle.Bundle{ Config: config.Root{ From 6ecd36096e44fc5983c090f07ee3ab1d26c8048c Mon Sep 17 00:00:00 2001 From: Yuri Barreira Date: Mon, 19 Oct 2026 17:18:06 -0300 Subject: [PATCH 4/4] Pin out-of-scope trigger shapes in table update validation Cover a trigger without a table_update block and an empty table list, both of which must not report the multi-table condition error. --- .../job_table_update_condition_test.go | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/bundle/config/validate/job_table_update_condition_test.go b/bundle/config/validate/job_table_update_condition_test.go index 747c63f17c4..d4fe70f2547 100644 --- a/bundle/config/validate/job_table_update_condition_test.go +++ b/bundle/config/validate/job_table_update_condition_test.go @@ -147,6 +147,28 @@ func TestJobTableUpdateConditionPluralTriggersSingleTable(t *testing.T) { require.NoError(t, diags.Error()) } +func TestJobTableUpdateConditionOtherTriggerShapes(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + Jobs: map[string]*resources.Job{ + // A trigger without a table_update block is out of scope. + "job1": { + Name: "job1", + Trigger: &jobs.TriggerSettings{PauseStatus: jobs.PauseStatusUnpaused}, + }, + // Zero tables cannot violate the multi-table rule. + "job2": tableUpdateJob(""), + }, + }, + }, + } + + diags := JobTableUpdateCondition().Apply(t.Context(), b) + require.Empty(t, diags) + require.NoError(t, diags.Error()) +} + func TestJobTableUpdateConditionNoTrigger(t *testing.T) { b := &bundle.Bundle{ Config: config.Root{