From 124e03b98845c90141585e4c8b7b56e3934e5028 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 11 Aug 2026 11:56:25 +0000 Subject: [PATCH 01/14] job_runs: add lifecycle.triggers.on_bundle_deploy Let users re-fire a run on every deploy via a trigger fingerprint in state, so unchanged config still plans recreate. File/value modes are parsed but rejected until follow-up PRs. --- .../bundles/job-runs-on-bundle-deploy.md | 1 + .../job_runs/on_bundle_deploy/databricks.yml | 18 ++++ .../job_runs/on_bundle_deploy/out.test.toml | 2 + .../job_runs/on_bundle_deploy/output.txt | 80 +++++++++++++++++ .../job_runs/on_bundle_deploy/script | 22 +++++ .../job_runs/on_bundle_deploy/test.toml | 4 + .../mutator/validate_job_run_triggers.go | 61 +++++++++++++ .../mutator/validate_job_run_triggers_test.go | 89 +++++++++++++++++++ bundle/config/resources/job_run.go | 28 +++++- bundle/config/resources/lifecycle.go | 30 +++++++ bundle/direct/dresources/job_run.go | 47 ++++++++-- bundle/direct/dresources/job_run_test.go | 22 +++++ bundle/direct/dresources/type_test.go | 4 + bundle/internal/schema/annotations.yml | 19 +++- bundle/phases/plan.go | 1 + bundle/schema/jsonschema.json | 66 +++++++++++++- 16 files changed, 482 insertions(+), 12 deletions(-) create mode 100644 .nextchanges/bundles/job-runs-on-bundle-deploy.md create mode 100644 acceptance/bundle/resources/job_runs/on_bundle_deploy/databricks.yml create mode 100644 acceptance/bundle/resources/job_runs/on_bundle_deploy/out.test.toml create mode 100644 acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt create mode 100644 acceptance/bundle/resources/job_runs/on_bundle_deploy/script create mode 100644 acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml create mode 100644 bundle/config/mutator/validate_job_run_triggers.go create mode 100644 bundle/config/mutator/validate_job_run_triggers_test.go diff --git a/.nextchanges/bundles/job-runs-on-bundle-deploy.md b/.nextchanges/bundles/job-runs-on-bundle-deploy.md new file mode 100644 index 00000000000..498e6d71018 --- /dev/null +++ b/.nextchanges/bundles/job-runs-on-bundle-deploy.md @@ -0,0 +1 @@ +direct: `resources.job_runs` can set `lifecycle.triggers.on_bundle_deploy: true` to re-fire the run on every bundle deploy, even when the run configuration is unchanged. diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/databricks.yml b/acceptance/bundle/resources/job_runs/on_bundle_deploy/databricks.yml new file mode 100644 index 00000000000..0ca892109c3 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/databricks.yml @@ -0,0 +1,18 @@ +bundle: + name: job-runs-on-bundle-deploy + +resources: + jobs: + my_job: + name: my-job + tasks: + - task_key: main + notebook_task: + notebook_path: /Workspace/test + + job_runs: + my_run: + job_id: ${resources.jobs.my_job.id} + lifecycle: + triggers: + - on_bundle_deploy: true diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/out.test.toml b/acceptance/bundle/resources/job_runs/on_bundle_deploy/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt new file mode 100644 index 00000000000..854fd528f56 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt @@ -0,0 +1,80 @@ + +=== first deploy triggers a run +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default/files... +Deploying resources... +job run [MY_RUN_ID]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID]?o=[NUMID] +job run [MY_RUN_ID]: SUCCESS +Updating deployment state... +Deployment complete! + +>>> read_id.py my_job +[MY_JOB_ID] + +>>> print_requests.py //jobs/run-now +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "job_id": [MY_JOB_ID] + } +} + +=== redeploy with unchanged config still re-fires +>>> [CLI] bundle plan +recreate job_runs.my_run + +Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default/files... +Deploying resources... +job run [MY_RUN_ID_2]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID_2]?o=[NUMID] +job run [MY_RUN_ID_2]: SUCCESS +Updating deployment state... +Deployment complete! + +>>> [CLI] bundle summary +Name: job-runs-on-bundle-deploy +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default +Resources: + Job Runs: + my_run: + Name: + URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID_2]?w=[NUMID] + Jobs: + my_job: + Name: my-job + URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]?w=[NUMID] + +=== second run-now after recreate +>>> print_requests.py --keep //jobs/runs/delete +{ + "method": "POST", + "path": "/api/2.2/jobs/runs/delete", + "body": { + "run_id": [MY_RUN_ID] + } +} + +>>> print_requests.py //jobs/run-now +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "job_id": [MY_JOB_ID] + } +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.job_runs.my_run + delete resources.jobs.my_job + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/script b/acceptance/bundle/resources/job_runs/on_bundle_deploy/script new file mode 100644 index 00000000000..5f88250e78e --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/script @@ -0,0 +1,22 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +title "first deploy triggers a run" +trace $CLI bundle deploy +trace read_id.py my_job +# Name the first run so the second becomes [MY_RUN_ID_2]. +read_id.py my_run > /dev/null +trace print_requests.py //jobs/run-now + +title "redeploy with unchanged config still re-fires" +trace $CLI bundle plan +trace $CLI bundle deploy +read_id.py my_run > /dev/null +trace $CLI bundle summary + +title "second run-now after recreate" +trace print_requests.py --keep //jobs/runs/delete +trace print_requests.py //jobs/run-now diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml b/acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml new file mode 100644 index 00000000000..4b94d8b58e9 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml @@ -0,0 +1,4 @@ +# job_runs is a direct-engine-only resource; the Terraform provider has no +# equivalent, so restrict the matrix to direct. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +RecordRequests = true diff --git a/bundle/config/mutator/validate_job_run_triggers.go b/bundle/config/mutator/validate_job_run_triggers.go new file mode 100644 index 00000000000..32c09185f37 --- /dev/null +++ b/bundle/config/mutator/validate_job_run_triggers.go @@ -0,0 +1,61 @@ +package mutator + +import ( + "context" + "fmt" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/libs/diag" +) + +type validateJobRunTriggers struct{} + +// ValidateJobRunTriggers checks lifecycle.triggers on job_runs. +func ValidateJobRunTriggers() bundle.Mutator { + return &validateJobRunTriggers{} +} + +func (*validateJobRunTriggers) Name() string { + return "ValidateJobRunTriggers" +} + +func (*validateJobRunTriggers) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnostics { + var diags diag.Diagnostics + for name, jr := range b.Config.Resources.JobRuns { + if jr == nil || jr.Lifecycle == nil { + continue + } + for i, t := range jr.Lifecycle.Triggers { + path := fmt.Sprintf("resources.job_runs.%s.lifecycle.triggers[%d]", name, i) + if t.FieldCount() != 1 { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: "lifecycle.triggers entry must set exactly one trigger mode", + Locations: b.Config.GetLocations(path), + }) + } + if t.OnBundleDeploy != nil && !*t.OnBundleDeploy { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: "lifecycle.triggers.on_bundle_deploy must be true when set", + Locations: b.Config.GetLocations(path + ".on_bundle_deploy"), + }) + } + if t.OnFileChange != "" { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: "lifecycle.triggers.on_file_change is not supported yet", + Locations: b.Config.GetLocations(path + ".on_file_change"), + }) + } + if t.OnValueChange != "" { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: "lifecycle.triggers.on_value_change is not supported yet", + Locations: b.Config.GetLocations(path + ".on_value_change"), + }) + } + } + } + return diags +} diff --git a/bundle/config/mutator/validate_job_run_triggers_test.go b/bundle/config/mutator/validate_job_run_triggers_test.go new file mode 100644 index 00000000000..ea30c5fa704 --- /dev/null +++ b/bundle/config/mutator/validate_job_run_triggers_test.go @@ -0,0 +1,89 @@ +package mutator_test + +import ( + "testing" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/bundle/config/mutator" + "github.com/databricks/cli/bundle/config/resources" + "github.com/stretchr/testify/assert" +) + +func TestValidateJobRunTriggers(t *testing.T) { + trueVal := true + falseVal := false + + tests := []struct { + name string + triggers []resources.JobRunTrigger + summary string + }{ + { + name: "on_bundle_deploy true", + triggers: []resources.JobRunTrigger{ + {OnBundleDeploy: &trueVal}, + }, + }, + { + name: "empty entry", + triggers: []resources.JobRunTrigger{ + {}, + }, + summary: "lifecycle.triggers entry must set exactly one trigger mode", + }, + { + name: "two modes", + triggers: []resources.JobRunTrigger{ + {OnBundleDeploy: &trueVal, OnFileChange: "src/**/*.py"}, + }, + summary: "lifecycle.triggers entry must set exactly one trigger mode", + }, + { + name: "on_bundle_deploy false", + triggers: []resources.JobRunTrigger{ + {OnBundleDeploy: &falseVal}, + }, + summary: "lifecycle.triggers.on_bundle_deploy must be true when set", + }, + { + name: "on_file_change unsupported", + triggers: []resources.JobRunTrigger{ + {OnFileChange: "src/**/*.py"}, + }, + summary: "lifecycle.triggers.on_file_change is not supported yet", + }, + { + name: "on_value_change unsupported", + triggers: []resources.JobRunTrigger{ + {OnValueChange: "${resources.jobs.foo.id}"}, + }, + summary: "lifecycle.triggers.on_value_change is not supported yet", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &bundle.Bundle{ + Config: config.Root{ + Resources: config.Resources{ + JobRuns: map[string]*resources.JobRun{ + "my_run": { + Lifecycle: &resources.JobRunLifecycle{ + Triggers: tt.triggers, + }, + }, + }, + }, + }, + } + diags := bundle.Apply(t.Context(), b, mutator.ValidateJobRunTriggers()) + if tt.summary == "" { + assert.Empty(t, diags) + return + } + assert.True(t, diags.HasError()) + assert.Equal(t, tt.summary, diags[0].Summary) + }) + } +} diff --git a/bundle/config/resources/job_run.go b/bundle/config/resources/job_run.go index 8db6ced76e1..51d88b8c8aa 100644 --- a/bundle/config/resources/job_run.go +++ b/bundle/config/resources/job_run.go @@ -14,18 +14,42 @@ import ( ) // JobRun is the bundle config for a triggered job run, described by the same -// fields as the Jobs RunNow request (embedded). It re-triggers only when its own -// config changes, not when the targeted job (stable job_id) changes. +// fields as the Jobs RunNow request (embedded). Without lifecycle triggers it +// re-fires only when its own config changes, not when the targeted job changes. type JobRun struct { BaseResource jobs.RunNow + // Lifecycle shadows BaseResource.Lifecycle so job_runs can set triggers. + Lifecycle *JobRunLifecycle `json:"lifecycle,omitempty"` + // ResolvedJobID holds the run's job_id loaded from state, used only to build // the run URL. Keeping it separate from RunNow.JobId (a ${resources.jobs.*.id} // reference) lets state loading preserve that reference and its plan dependency. ResolvedJobID int64 `json:"resolved_job_id,omitempty" bundle:"internal"` } +// GetLifecycle returns the job_runs lifecycle, including triggers. +func (r *JobRun) GetLifecycle() LifecycleConfig { + if r.Lifecycle == nil { + return JobRunLifecycle{} + } + return *r.Lifecycle +} + +// HasOnBundleDeploy reports whether any trigger re-fires on every deploy. +func (r *JobRun) HasOnBundleDeploy() bool { + if r.Lifecycle == nil { + return false + } + for _, t := range r.Lifecycle.Triggers { + if t.OnBundleDeploy != nil && *t.OnBundleDeploy { + return true + } + } + return false +} + func (r *JobRun) UnmarshalJSON(b []byte) error { return marshal.Unmarshal(b, r) } diff --git a/bundle/config/resources/lifecycle.go b/bundle/config/resources/lifecycle.go index db2b130d313..7d2e38d8f50 100644 --- a/bundle/config/resources/lifecycle.go +++ b/bundle/config/resources/lifecycle.go @@ -25,3 +25,33 @@ type LifecycleWithStarted struct { // Supported only for apps, clusters, and sql_warehouses. Started *bool `json:"started,omitempty"` } + +// JobRunLifecycle adds run-fire triggers; other resources keep base Lifecycle only. +type JobRunLifecycle struct { + Lifecycle + + // Without triggers, the run re-fires only when its own config changes. + Triggers []JobRunTrigger `json:"triggers,omitempty"` +} + +// JobRunTrigger is one lifecycle.triggers entry. Exactly one field must be set. +type JobRunTrigger struct { + OnBundleDeploy *bool `json:"on_bundle_deploy,omitempty"` + OnFileChange string `json:"on_file_change,omitempty"` + OnValueChange string `json:"on_value_change,omitempty"` +} + +// FieldCount returns how many trigger modes are set on this entry. +func (t JobRunTrigger) FieldCount() int { + n := 0 + if t.OnBundleDeploy != nil { + n++ + } + if t.OnFileChange != "" { + n++ + } + if t.OnValueChange != "" { + n++ + } + return n +} diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index ac549b876cd..38979d6f95b 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -26,12 +26,22 @@ import ( // jobRunTimeout matches the timeout `bundle run` allows a run (bundle/run/job.go). const jobRunTimeout = 24 * time.Hour +// JobRunTriggersState is the persisted fingerprint of lifecycle.triggers. +type JobRunTriggersState struct { + // Fresh UUID each plan when on_bundle_deploy is set; Old!=New forces recreate. + // A sticky true would be skipped as missing_in_remote when Old==New. + OnBundleDeploy string `json:"on_bundle_deploy,omitempty"` +} + // JobRunState is the RunNow request plus the outcome required for planning. type JobRunState struct { jobs.RunNow // Always SUCCESS during planning and cleared before persistence. ResultState jobs.RunResultState `json:"result_state,omitempty"` + + // Absent from RemoteType (knownMissingInRemoteType); local diff drives triggers. + Triggers *JobRunTriggersState `json:"triggers,omitempty"` } func (s *JobRunState) UnmarshalJSON(b []byte) error { @@ -79,10 +89,15 @@ func (*ResourceJobRun) New(client *databricks.WorkspaceClient) *ResourceJobRun { } func (*ResourceJobRun) PrepareState(input *resources.JobRun) *JobRunState { - return &JobRunState{ + state := &JobRunState{ RunNow: input.RunNow, ResultState: jobs.RunResultStateSuccess, + Triggers: nil, + } + if input.HasOnBundleDeploy() { + state.Triggers = &JobRunTriggersState{OnBundleDeploy: uuid.NewString()} } + return state } // makeJobRunRemote maps the GetRun response into the RunNow-shaped remote: GET @@ -158,7 +173,12 @@ func (r *ResourceJobRun) DoRead(ctx context.Context, id string) (*JobRunRemote, // RemapState extracts the fields used for diffing: the RunNow request and the // outcome the run reached. func (*ResourceJobRun) RemapState(remote *JobRunRemote) *JobRunState { - return &JobRunState{RunNow: remote.RunNow, ResultState: remote.ResultState} + return &JobRunState{ + RunNow: remote.RunNow, + ResultState: remote.ResultState, + // Triggers are local-only fingerprints; RemoteType has nothing to copy. + Triggers: nil, + } } func (r *ResourceJobRun) DoCreate(ctx context.Context, config *JobRunState) (string, *JobRunRemote, error) { @@ -347,14 +367,27 @@ func (r *ResourceJobRun) DoUpdate(ctx context.Context, id string, config *JobRun // still going, so a run that may yet succeed is adopted and waited on. A run that // stopped without succeeding keeps its recreate. A SKIPPED run reports no // result_state either, so the lifecycle state is what tells the two apart. +// Clearing triggers.on_bundle_deploy is skipped so removing the trigger does not +// fire one last run. func (*ResourceJobRun) OverrideChangeDesc(_ context.Context, path *structpath.PathNode, change *ChangeDesc, remote *JobRunRemote) error { - // The planner passes no remote state when the run could not be read. - if path.String() != "result_state" || remote == nil || runIsTerminal(remote.State.LifeCycleState) { + switch path.String() { + case "triggers.on_bundle_deploy": + if change.New == nil || change.New == "" { + change.Action = deployplan.Skip + change.Reason = "trigger removed" + } + return nil + case "result_state": + // The planner passes no remote state when the run could not be read. + if remote == nil || runIsTerminal(remote.State.LifeCycleState) { + return nil + } + change.Action = deployplan.Update + change.Reason = "run in progress" + return nil + default: return nil } - change.Action = deployplan.Update - change.Reason = "run in progress" - return nil } // DoDelete deletes the run via jobs/runs/delete, on both destroy and the diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index 62a22a9745f..34f1ede4d80 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -342,6 +342,28 @@ func TestJobRunPrepareStateRequiresSuccess(t *testing.T) { assert.Equal(t, jobs.RunResultStateSuccess, state.ResultState) } +func TestJobRunPrepareStateOnBundleDeploy(t *testing.T) { + t.Run("unset", func(t *testing.T) { + state := (&ResourceJobRun{}).PrepareState(&resources.JobRun{}) + assert.Nil(t, state.Triggers) + }) + + t.Run("armed", func(t *testing.T) { + on := true + input := &resources.JobRun{ + Lifecycle: &resources.JobRunLifecycle{ + Triggers: []resources.JobRunTrigger{{OnBundleDeploy: &on}}, + }, + } + first := (&ResourceJobRun{}).PrepareState(input) + require.NotNil(t, first.Triggers) + assert.NotEmpty(t, first.Triggers.OnBundleDeploy) + + second := (&ResourceJobRun{}).PrepareState(input) + assert.NotEqual(t, first.Triggers.OnBundleDeploy, second.Triggers.OnBundleDeploy) + }) +} + // The planner diffs RemapState(remote) against PrepareState(config), so a run // that did not end in SUCCESS has to surface as a difference on result_state. func TestJobRunRemapStateCarriesTheOutcome(t *testing.T) { diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index 109b98c3a86..48a9e3a350b 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -56,6 +56,10 @@ var knownMissingInRemoteType = map[string][]string{ "vector_search_endpoints": { "usage_policy_id", }, + "job_runs": { + // Local trigger fingerprints; the Jobs API has nothing corresponding. + "triggers", + }, } // commonMissingInStateType lists fields that are commonly missing across all resource types. diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 60bedb38d16..6a352d120a2 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -978,7 +978,24 @@ resources: "$fields": "lifecycle": "description": |- - Settings that control the deployment lifecycle of the resource, such as preventing it from being destroyed. + Settings that control the deployment lifecycle of the resource, such as preventing it from being destroyed and when the run re-fires. + "$fields": + "prevent_destroy": + "description": |- + Lifecycle setting to prevent the resource from being destroyed. + "triggers": + "description": |- + Conditions that re-fire this job run. Without triggers, the run re-fires only when its own configuration changes. + "$fields": + "on_bundle_deploy": + "description": |- + If true, re-fire the run on every bundle deploy, even when the run configuration is unchanged. + "on_file_change": + "description": |- + Re-fire the run when the contents of files matching this glob change. Not yet supported. + "on_value_change": + "description": |- + Re-fire the run when this bundle value expression changes. Not yet supported. "python_named_params": "description": |- PLACEHOLDER diff --git a/bundle/phases/plan.go b/bundle/phases/plan.go index aee481b2b14..3db0864c2c4 100644 --- a/bundle/phases/plan.go +++ b/bundle/phases/plan.go @@ -29,6 +29,7 @@ func PreDeployChecks(ctx context.Context, b *bundle.Bundle, isPlan bool, engine mutator.ValidateDirectOnlyResources(engine), mutator.ValidateLifecycleStarted(engine), mutator.ValidateCascadeOnDestroy(engine), + mutator.ValidateJobRunTriggers(), statemgmt.CheckRunningResource(engine), ) } diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index b4084979746..9e7b726ddbf 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1199,8 +1199,8 @@ "$ref": "#/$defs/map/string" }, "lifecycle": { - "description": "Settings that control the deployment lifecycle of the resource, such as preventing it from being destroyed.", - "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + "description": "Settings that control the deployment lifecycle of the resource, such as preventing it from being destroyed and when the run re-fires.", + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.JobRunLifecycle" }, "notebook_params": { "description": "[Private Preview] A map from keys to values for jobs with notebook task, for example `\"notebook_params\": {\"name\": \"john doe\", \"age\": \"35\"}`.\nThe map is passed to the notebook and is accessible through the [dbutils.widgets.get](https://docs.databricks.com/dev-tools/databricks-utils.html) function.\n\nIf not specified upon `run-now`, the triggered run uses the job’s base parameters.\n\nnotebook_params cannot be specified in conjunction with jar_params.\n\n⚠ **Deprecation note** Use [job parameters](https://docs.databricks.com/jobs/job-parameters.html#job-parameter-pushdown) to pass information down to tasks.\n\nThe JSON representation of this field (for example `{\"notebook_params\":{\"name\":\"john doe\",\"age\":\"35\"}}`) cannot exceed 10,000 bytes.", @@ -1270,6 +1270,54 @@ } ] }, + "resources.JobRunLifecycle": { + "oneOf": [ + { + "type": "object", + "properties": { + "prevent_destroy": { + "description": "Lifecycle setting to prevent the resource from being destroyed.", + "$ref": "#/$defs/bool" + }, + "triggers": { + "description": "Conditions that re-fire this job run. Without triggers, the run re-fires only when its own configuration changes.", + "$ref": "#/$defs/slice/github.com/databricks/cli/bundle/config/resources.JobRunTrigger" + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "resources.JobRunTrigger": { + "oneOf": [ + { + "type": "object", + "properties": { + "on_bundle_deploy": { + "description": "If true, re-fire the run on every bundle deploy, even when the run configuration is unchanged.", + "$ref": "#/$defs/bool" + }, + "on_file_change": { + "description": "Re-fire the run when the contents of files matching this glob change. Not yet supported.", + "$ref": "#/$defs/string" + }, + "on_value_change": { + "description": "Re-fire the run when this bundle value expression changes. Not yet supported.", + "$ref": "#/$defs/string" + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.Lifecycle": { "oneOf": [ { @@ -15159,6 +15207,20 @@ } ] }, + "resources.JobRunTrigger": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.JobRunTrigger" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\.\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.MlflowExperimentPermission": { "oneOf": [ { From a4e4a8e17a1ea307887e369009a378dee88c03ea Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 11 Aug 2026 12:34:17 +0000 Subject: [PATCH 02/14] job_runs: drop unsupported on_file_change and on_value_change triggers Ship only on_bundle_deploy for now; add the other modes when implemented. --- .../mutator/validate_job_run_triggers.go | 21 ++++------------- .../mutator/validate_job_run_triggers_test.go | 23 +------------------ bundle/config/resources/lifecycle.go | 21 ++--------------- bundle/internal/schema/annotations.yml | 6 ----- bundle/schema/jsonschema.json | 8 ------- 5 files changed, 7 insertions(+), 72 deletions(-) diff --git a/bundle/config/mutator/validate_job_run_triggers.go b/bundle/config/mutator/validate_job_run_triggers.go index 32c09185f37..395dab5ae42 100644 --- a/bundle/config/mutator/validate_job_run_triggers.go +++ b/bundle/config/mutator/validate_job_run_triggers.go @@ -27,34 +27,21 @@ func (*validateJobRunTriggers) Apply(_ context.Context, b *bundle.Bundle) diag.D } for i, t := range jr.Lifecycle.Triggers { path := fmt.Sprintf("resources.job_runs.%s.lifecycle.triggers[%d]", name, i) - if t.FieldCount() != 1 { + if t.OnBundleDeploy == nil { diags = diags.Append(diag.Diagnostic{ Severity: diag.Error, - Summary: "lifecycle.triggers entry must set exactly one trigger mode", + Summary: "lifecycle.triggers entry must set on_bundle_deploy: true", Locations: b.Config.GetLocations(path), }) + continue } - if t.OnBundleDeploy != nil && !*t.OnBundleDeploy { + if !*t.OnBundleDeploy { diags = diags.Append(diag.Diagnostic{ Severity: diag.Error, Summary: "lifecycle.triggers.on_bundle_deploy must be true when set", Locations: b.Config.GetLocations(path + ".on_bundle_deploy"), }) } - if t.OnFileChange != "" { - diags = diags.Append(diag.Diagnostic{ - Severity: diag.Error, - Summary: "lifecycle.triggers.on_file_change is not supported yet", - Locations: b.Config.GetLocations(path + ".on_file_change"), - }) - } - if t.OnValueChange != "" { - diags = diags.Append(diag.Diagnostic{ - Severity: diag.Error, - Summary: "lifecycle.triggers.on_value_change is not supported yet", - Locations: b.Config.GetLocations(path + ".on_value_change"), - }) - } } } return diags diff --git a/bundle/config/mutator/validate_job_run_triggers_test.go b/bundle/config/mutator/validate_job_run_triggers_test.go index ea30c5fa704..855fecd2ea9 100644 --- a/bundle/config/mutator/validate_job_run_triggers_test.go +++ b/bundle/config/mutator/validate_job_run_triggers_test.go @@ -30,14 +30,7 @@ func TestValidateJobRunTriggers(t *testing.T) { triggers: []resources.JobRunTrigger{ {}, }, - summary: "lifecycle.triggers entry must set exactly one trigger mode", - }, - { - name: "two modes", - triggers: []resources.JobRunTrigger{ - {OnBundleDeploy: &trueVal, OnFileChange: "src/**/*.py"}, - }, - summary: "lifecycle.triggers entry must set exactly one trigger mode", + summary: "lifecycle.triggers entry must set on_bundle_deploy: true", }, { name: "on_bundle_deploy false", @@ -46,20 +39,6 @@ func TestValidateJobRunTriggers(t *testing.T) { }, summary: "lifecycle.triggers.on_bundle_deploy must be true when set", }, - { - name: "on_file_change unsupported", - triggers: []resources.JobRunTrigger{ - {OnFileChange: "src/**/*.py"}, - }, - summary: "lifecycle.triggers.on_file_change is not supported yet", - }, - { - name: "on_value_change unsupported", - triggers: []resources.JobRunTrigger{ - {OnValueChange: "${resources.jobs.foo.id}"}, - }, - summary: "lifecycle.triggers.on_value_change is not supported yet", - }, } for _, tt := range tests { diff --git a/bundle/config/resources/lifecycle.go b/bundle/config/resources/lifecycle.go index 7d2e38d8f50..16f1334cce7 100644 --- a/bundle/config/resources/lifecycle.go +++ b/bundle/config/resources/lifecycle.go @@ -34,24 +34,7 @@ type JobRunLifecycle struct { Triggers []JobRunTrigger `json:"triggers,omitempty"` } -// JobRunTrigger is one lifecycle.triggers entry. Exactly one field must be set. +// JobRunTrigger is one lifecycle.triggers entry. type JobRunTrigger struct { - OnBundleDeploy *bool `json:"on_bundle_deploy,omitempty"` - OnFileChange string `json:"on_file_change,omitempty"` - OnValueChange string `json:"on_value_change,omitempty"` -} - -// FieldCount returns how many trigger modes are set on this entry. -func (t JobRunTrigger) FieldCount() int { - n := 0 - if t.OnBundleDeploy != nil { - n++ - } - if t.OnFileChange != "" { - n++ - } - if t.OnValueChange != "" { - n++ - } - return n + OnBundleDeploy *bool `json:"on_bundle_deploy,omitempty"` } diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 6a352d120a2..1314ae9bd7c 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -990,12 +990,6 @@ resources: "on_bundle_deploy": "description": |- If true, re-fire the run on every bundle deploy, even when the run configuration is unchanged. - "on_file_change": - "description": |- - Re-fire the run when the contents of files matching this glob change. Not yet supported. - "on_value_change": - "description": |- - Re-fire the run when this bundle value expression changes. Not yet supported. "python_named_params": "description": |- PLACEHOLDER diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 9e7b726ddbf..cdbeac606ad 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1300,14 +1300,6 @@ "on_bundle_deploy": { "description": "If true, re-fire the run on every bundle deploy, even when the run configuration is unchanged.", "$ref": "#/$defs/bool" - }, - "on_file_change": { - "description": "Re-fire the run when the contents of files matching this glob change. Not yet supported.", - "$ref": "#/$defs/string" - }, - "on_value_change": { - "description": "Re-fire the run when this bundle value expression changes. Not yet supported.", - "$ref": "#/$defs/string" } }, "additionalProperties": false From 0dd4c7d018af4f2fcb62d6e2ea1d9f9c314a5bd3 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 11 Aug 2026 12:39:24 +0000 Subject: [PATCH 03/14] job_runs: phrase on_bundle_deploy comments positively Say what the trigger does instead of what it does not. --- .nextchanges/bundles/job-runs-on-bundle-deploy.md | 2 +- .../resources/job_runs/on_bundle_deploy/output.txt | 2 +- .../bundle/resources/job_runs/on_bundle_deploy/script | 2 +- .../resources/job_runs/on_bundle_deploy/test.toml | 3 +-- bundle/config/resources/job_run.go | 4 ++-- bundle/config/resources/lifecycle.go | 4 ++-- bundle/direct/dresources/job_run.go | 10 ++++------ bundle/direct/dresources/type_test.go | 2 +- bundle/internal/schema/annotations.yml | 4 ++-- bundle/schema/jsonschema.json | 4 ++-- 10 files changed, 17 insertions(+), 20 deletions(-) diff --git a/.nextchanges/bundles/job-runs-on-bundle-deploy.md b/.nextchanges/bundles/job-runs-on-bundle-deploy.md index 498e6d71018..21acd2891c5 100644 --- a/.nextchanges/bundles/job-runs-on-bundle-deploy.md +++ b/.nextchanges/bundles/job-runs-on-bundle-deploy.md @@ -1 +1 @@ -direct: `resources.job_runs` can set `lifecycle.triggers.on_bundle_deploy: true` to re-fire the run on every bundle deploy, even when the run configuration is unchanged. +direct: `resources.job_runs` can set `lifecycle.triggers.on_bundle_deploy: true` to re-fire the run on every bundle deploy. diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt index 854fd528f56..95978285be7 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt @@ -20,7 +20,7 @@ Deployment complete! } } -=== redeploy with unchanged config still re-fires +=== redeploy re-fires with unchanged config >>> [CLI] bundle plan recreate job_runs.my_run diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/script b/acceptance/bundle/resources/job_runs/on_bundle_deploy/script index 5f88250e78e..e482c1286e0 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/script +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/script @@ -11,7 +11,7 @@ trace read_id.py my_job read_id.py my_run > /dev/null trace print_requests.py //jobs/run-now -title "redeploy with unchanged config still re-fires" +title "redeploy re-fires with unchanged config" trace $CLI bundle plan trace $CLI bundle deploy read_id.py my_run > /dev/null diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml b/acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml index 4b94d8b58e9..e61157b3197 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml @@ -1,4 +1,3 @@ -# job_runs is a direct-engine-only resource; the Terraform provider has no -# equivalent, so restrict the matrix to direct. +# Restrict the matrix to direct: job_runs is a direct-engine-only resource. EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] RecordRequests = true diff --git a/bundle/config/resources/job_run.go b/bundle/config/resources/job_run.go index 51d88b8c8aa..1c648403de5 100644 --- a/bundle/config/resources/job_run.go +++ b/bundle/config/resources/job_run.go @@ -14,8 +14,8 @@ import ( ) // JobRun is the bundle config for a triggered job run, described by the same -// fields as the Jobs RunNow request (embedded). Without lifecycle triggers it -// re-fires only when its own config changes, not when the targeted job changes. +// fields as the Jobs RunNow request (embedded). By default it re-fires when its +// own configuration changes; lifecycle.triggers can add further conditions. type JobRun struct { BaseResource jobs.RunNow diff --git a/bundle/config/resources/lifecycle.go b/bundle/config/resources/lifecycle.go index 16f1334cce7..88d130239ee 100644 --- a/bundle/config/resources/lifecycle.go +++ b/bundle/config/resources/lifecycle.go @@ -26,11 +26,11 @@ type LifecycleWithStarted struct { Started *bool `json:"started,omitempty"` } -// JobRunLifecycle adds run-fire triggers; other resources keep base Lifecycle only. +// JobRunLifecycle extends Lifecycle with run-fire triggers. type JobRunLifecycle struct { Lifecycle - // Without triggers, the run re-fires only when its own config changes. + // Triggers that cause the run to re-fire (in addition to config changes). Triggers []JobRunTrigger `json:"triggers,omitempty"` } diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 38979d6f95b..ccc63f585be 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -28,8 +28,7 @@ const jobRunTimeout = 24 * time.Hour // JobRunTriggersState is the persisted fingerprint of lifecycle.triggers. type JobRunTriggersState struct { - // Fresh UUID each plan when on_bundle_deploy is set; Old!=New forces recreate. - // A sticky true would be skipped as missing_in_remote when Old==New. + // Fresh UUID each plan when on_bundle_deploy is set so Old!=New forces recreate. OnBundleDeploy string `json:"on_bundle_deploy,omitempty"` } @@ -40,7 +39,7 @@ type JobRunState struct { // Always SUCCESS during planning and cleared before persistence. ResultState jobs.RunResultState `json:"result_state,omitempty"` - // Absent from RemoteType (knownMissingInRemoteType); local diff drives triggers. + // Local-only trigger fingerprints; listed in knownMissingInRemoteType. Triggers *JobRunTriggersState `json:"triggers,omitempty"` } @@ -176,7 +175,7 @@ func (*ResourceJobRun) RemapState(remote *JobRunRemote) *JobRunState { return &JobRunState{ RunNow: remote.RunNow, ResultState: remote.ResultState, - // Triggers are local-only fingerprints; RemoteType has nothing to copy. + // Local-only trigger fingerprints stay unset on the remapped remote. Triggers: nil, } } @@ -367,8 +366,7 @@ func (r *ResourceJobRun) DoUpdate(ctx context.Context, id string, config *JobRun // still going, so a run that may yet succeed is adopted and waited on. A run that // stopped without succeeding keeps its recreate. A SKIPPED run reports no // result_state either, so the lifecycle state is what tells the two apart. -// Clearing triggers.on_bundle_deploy is skipped so removing the trigger does not -// fire one last run. +// Removing triggers.on_bundle_deploy is a no-op so the existing run stays in place. func (*ResourceJobRun) OverrideChangeDesc(_ context.Context, path *structpath.PathNode, change *ChangeDesc, remote *JobRunRemote) error { switch path.String() { case "triggers.on_bundle_deploy": diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index 48a9e3a350b..72d9c16ee8d 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -57,7 +57,7 @@ var knownMissingInRemoteType = map[string][]string{ "usage_policy_id", }, "job_runs": { - // Local trigger fingerprints; the Jobs API has nothing corresponding. + // Local-only trigger fingerprints stored in state. "triggers", }, } diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 1314ae9bd7c..a411b6e02b8 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -985,11 +985,11 @@ resources: Lifecycle setting to prevent the resource from being destroyed. "triggers": "description": |- - Conditions that re-fire this job run. Without triggers, the run re-fires only when its own configuration changes. + Conditions that re-fire this job run (in addition to configuration changes). "$fields": "on_bundle_deploy": "description": |- - If true, re-fire the run on every bundle deploy, even when the run configuration is unchanged. + If true, re-fire the run on every bundle deploy. "python_named_params": "description": |- PLACEHOLDER diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index cdbeac606ad..e38b58ca6fc 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1280,7 +1280,7 @@ "$ref": "#/$defs/bool" }, "triggers": { - "description": "Conditions that re-fire this job run. Without triggers, the run re-fires only when its own configuration changes.", + "description": "Conditions that re-fire this job run (in addition to configuration changes).", "$ref": "#/$defs/slice/github.com/databricks/cli/bundle/config/resources.JobRunTrigger" } }, @@ -1298,7 +1298,7 @@ "type": "object", "properties": { "on_bundle_deploy": { - "description": "If true, re-fire the run on every bundle deploy, even when the run configuration is unchanged.", + "description": "If true, re-fire the run on every bundle deploy.", "$ref": "#/$defs/bool" } }, From 47325b20829e1221e27debec484c7428959de792 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 11 Aug 2026 13:11:42 +0000 Subject: [PATCH 04/14] job_runs: drop OverrideChangeDesc skip when removing on_bundle_deploy Trigger fingerprint removal can recreate once; keep the same model for future triggers. --- bundle/direct/dresources/job_run.go | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index ccc63f585be..653f3a0220b 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -366,26 +366,14 @@ func (r *ResourceJobRun) DoUpdate(ctx context.Context, id string, config *JobRun // still going, so a run that may yet succeed is adopted and waited on. A run that // stopped without succeeding keeps its recreate. A SKIPPED run reports no // result_state either, so the lifecycle state is what tells the two apart. -// Removing triggers.on_bundle_deploy is a no-op so the existing run stays in place. func (*ResourceJobRun) OverrideChangeDesc(_ context.Context, path *structpath.PathNode, change *ChangeDesc, remote *JobRunRemote) error { - switch path.String() { - case "triggers.on_bundle_deploy": - if change.New == nil || change.New == "" { - change.Action = deployplan.Skip - change.Reason = "trigger removed" - } - return nil - case "result_state": - // The planner passes no remote state when the run could not be read. - if remote == nil || runIsTerminal(remote.State.LifeCycleState) { - return nil - } - change.Action = deployplan.Update - change.Reason = "run in progress" - return nil - default: + // The planner passes no remote state when the run could not be read. + if path.String() != "result_state" || remote == nil || runIsTerminal(remote.State.LifeCycleState) { return nil } + change.Action = deployplan.Update + change.Reason = "run in progress" + return nil } // DoDelete deletes the run via jobs/runs/delete, on both destroy and the From 9d04655b1d51254cb4d9cb2655689a164ee28c02 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 11 Aug 2026 13:18:20 +0000 Subject: [PATCH 05/14] job_runs: reject on_bundle_deploy together with prevent_destroy Every-deploy recreate cannot succeed when destroy is forbidden. --- .../mutator/validate_job_run_triggers.go | 8 +++++++ .../mutator/validate_job_run_triggers_test.go | 22 +++++++++++++++---- bundle/internal/schema/annotations.yml | 2 +- bundle/schema/jsonschema.json | 2 +- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/bundle/config/mutator/validate_job_run_triggers.go b/bundle/config/mutator/validate_job_run_triggers.go index 395dab5ae42..3c74ce2f721 100644 --- a/bundle/config/mutator/validate_job_run_triggers.go +++ b/bundle/config/mutator/validate_job_run_triggers.go @@ -25,6 +25,14 @@ func (*validateJobRunTriggers) Apply(_ context.Context, b *bundle.Bundle) diag.D if jr == nil || jr.Lifecycle == nil { continue } + // Recreate-every-deploy cannot coexist with prevent_destroy. + if jr.HasOnBundleDeploy() && jr.Lifecycle.PreventDestroy { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: "lifecycle.triggers.on_bundle_deploy is incompatible with lifecycle.prevent_destroy", + Locations: b.Config.GetLocations(fmt.Sprintf("resources.job_runs.%s.lifecycle", name)), + }) + } for i, t := range jr.Lifecycle.Triggers { path := fmt.Sprintf("resources.job_runs.%s.lifecycle.triggers[%d]", name, i) if t.OnBundleDeploy == nil { diff --git a/bundle/config/mutator/validate_job_run_triggers_test.go b/bundle/config/mutator/validate_job_run_triggers_test.go index 855fecd2ea9..9c225457471 100644 --- a/bundle/config/mutator/validate_job_run_triggers_test.go +++ b/bundle/config/mutator/validate_job_run_triggers_test.go @@ -15,9 +15,10 @@ func TestValidateJobRunTriggers(t *testing.T) { falseVal := false tests := []struct { - name string - triggers []resources.JobRunTrigger - summary string + name string + triggers []resources.JobRunTrigger + preventDestroy bool + summary string }{ { name: "on_bundle_deploy true", @@ -39,6 +40,18 @@ func TestValidateJobRunTriggers(t *testing.T) { }, summary: "lifecycle.triggers.on_bundle_deploy must be true when set", }, + { + name: "on_bundle_deploy with prevent_destroy", + triggers: []resources.JobRunTrigger{ + {OnBundleDeploy: &trueVal}, + }, + preventDestroy: true, + summary: "lifecycle.triggers.on_bundle_deploy is incompatible with lifecycle.prevent_destroy", + }, + { + name: "prevent_destroy alone", + preventDestroy: true, + }, } for _, tt := range tests { @@ -49,7 +62,8 @@ func TestValidateJobRunTriggers(t *testing.T) { JobRuns: map[string]*resources.JobRun{ "my_run": { Lifecycle: &resources.JobRunLifecycle{ - Triggers: tt.triggers, + Lifecycle: resources.Lifecycle{PreventDestroy: tt.preventDestroy}, + Triggers: tt.triggers, }, }, }, diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index a411b6e02b8..a47e8151681 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -989,7 +989,7 @@ resources: "$fields": "on_bundle_deploy": "description": |- - If true, re-fire the run on every bundle deploy. + If true, re-fire the run on every bundle deploy. Incompatible with lifecycle.prevent_destroy. "python_named_params": "description": |- PLACEHOLDER diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index e38b58ca6fc..e0c710428a3 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1298,7 +1298,7 @@ "type": "object", "properties": { "on_bundle_deploy": { - "description": "If true, re-fire the run on every bundle deploy.", + "description": "If true, re-fire the run on every bundle deploy. Incompatible with lifecycle.prevent_destroy.", "$ref": "#/$defs/bool" } }, From 9e15dbab460f91d5690b17acb8bc2ac9f1b62d23 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 11 Aug 2026 13:32:33 +0000 Subject: [PATCH 06/14] job_runs: refresh acceptance goldens after main merge Include idempotency_token in on_bundle_deploy output and regenerate refschema fields for JobRunLifecycle triggers. --- acceptance/bundle/refschema/out.fields.txt | 6 ++++++ .../bundle/resources/job_runs/on_bundle_deploy/output.txt | 2 ++ 2 files changed, 8 insertions(+) diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index 6c2b033fa97..072a48b26b9 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -845,8 +845,12 @@ resources.job_runs.*.jar_params[*] string ALL resources.job_runs.*.job_id int64 ALL resources.job_runs.*.job_parameters map[string]string ALL resources.job_runs.*.job_parameters.* string ALL +resources.job_runs.*.lifecycle *resources.JobRunLifecycle INPUT resources.job_runs.*.lifecycle resources.Lifecycle INPUT resources.job_runs.*.lifecycle.prevent_destroy bool INPUT +resources.job_runs.*.lifecycle.triggers []resources.JobRunTrigger INPUT +resources.job_runs.*.lifecycle.triggers[*] resources.JobRunTrigger INPUT +resources.job_runs.*.lifecycle.triggers[*].on_bundle_deploy *bool INPUT resources.job_runs.*.modified_status string INPUT resources.job_runs.*.notebook_params map[string]string ALL resources.job_runs.*.notebook_params.* string ALL @@ -885,6 +889,8 @@ resources.job_runs.*.state.queue_reason string REMOTE resources.job_runs.*.state.result_state jobs.RunResultState REMOTE resources.job_runs.*.state.state_message string REMOTE resources.job_runs.*.state.user_cancelled_or_timedout bool REMOTE +resources.job_runs.*.triggers *dresources.JobRunTriggersState STATE +resources.job_runs.*.triggers.on_bundle_deploy string STATE resources.job_runs.*.url string INPUT resources.jobs.*.budget_policy_id string ALL resources.jobs.*.continuous *jobs.Continuous ALL diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt index 95978285be7..36dd5bad024 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt @@ -16,6 +16,7 @@ Deployment complete! "method": "POST", "path": "/api/2.2/jobs/run-now", "body": { + "idempotency_token": "[UUID]", "job_id": [MY_JOB_ID] } } @@ -65,6 +66,7 @@ Resources: "method": "POST", "path": "/api/2.2/jobs/run-now", "body": { + "idempotency_token": "[UUID]", "job_id": [MY_JOB_ID] } } From b2abedf6ffa86401c5f1cfae9f53757ceecc4332 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 11 Aug 2026 14:32:04 +0000 Subject: [PATCH 07/14] job_runs: validate lifecycle.triggers during initialize Catch invalid on_bundle_deploy configs at bundle validate, not only plan/deploy. --- bundle/config/mutator/validate_job_run_triggers.go | 2 +- bundle/phases/initialize.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/bundle/config/mutator/validate_job_run_triggers.go b/bundle/config/mutator/validate_job_run_triggers.go index 3c74ce2f721..a7c5ada925e 100644 --- a/bundle/config/mutator/validate_job_run_triggers.go +++ b/bundle/config/mutator/validate_job_run_triggers.go @@ -10,7 +10,7 @@ import ( type validateJobRunTriggers struct{} -// ValidateJobRunTriggers checks lifecycle.triggers on job_runs. +// ValidateJobRunTriggers rejects invalid lifecycle.triggers on job_runs. func ValidateJobRunTriggers() bundle.Mutator { return &validateJobRunTriggers{} } diff --git a/bundle/phases/initialize.go b/bundle/phases/initialize.go index a130da820d6..b15e1c30df6 100644 --- a/bundle/phases/initialize.go +++ b/bundle/phases/initialize.go @@ -188,6 +188,9 @@ func Initialize(ctx context.Context, b *bundle.Bundle) { // Reject configured job_runs.idempotency_token; the CLI sets it on run-now. validate.ValidateJobRunIdempotencyToken(), + // Reject invalid job_runs.lifecycle.triggers (empty, false, prevent_destroy). + mutator.ValidateJobRunTriggers(), + // Reads (dynamic): * (strings) (searches for ${resources.*} references) // Warns (TF engine) or errors (direct engine) when a cross-resource reference // points to a Terraform-only field with no DABs equivalent. From 499040cf68fe82ca27224f8a24958a74fb20525a Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Mon, 17 Aug 2026 12:23:16 +0000 Subject: [PATCH 08/14] job_runs: keep on_bundle_deploy removal drift-free and nest state under lifecycle Removing lifecycle.triggers.on_bundle_deploy left a UUID fingerprint in state that the next plan compared against nil, so the run was recreated one last time after the user had already opted out. OverrideChangeDesc now skips the change when the fingerprint is cleared, at whichever path structdiff reports it. The fingerprint also moves from a top-level "triggers" field to lifecycle.triggers, mirroring the config shape and leaving room for a future Jobs API field of the same name. --- .../bundles/job-runs-on-bundle-deploy.md | 2 +- acceptance/bundle/refschema/out.fields.txt | 5 +- .../job_runs/on_bundle_deploy/output.txt | 12 +++++ .../job_runs/on_bundle_deploy/script | 11 +++++ bundle/direct/dresources/job_run.go | 47 ++++++++++++++----- bundle/direct/dresources/job_run_test.go | 46 ++++++++++++++++-- bundle/direct/dresources/type_test.go | 9 +++- 7 files changed, 111 insertions(+), 21 deletions(-) diff --git a/.nextchanges/bundles/job-runs-on-bundle-deploy.md b/.nextchanges/bundles/job-runs-on-bundle-deploy.md index 21acd2891c5..126b403edc3 100644 --- a/.nextchanges/bundles/job-runs-on-bundle-deploy.md +++ b/.nextchanges/bundles/job-runs-on-bundle-deploy.md @@ -1 +1 @@ -direct: `resources.job_runs` can set `lifecycle.triggers.on_bundle_deploy: true` to re-fire the run on every bundle deploy. +direct: `resources.job_runs` can set `lifecycle.triggers.on_bundle_deploy: true` to re-fire the run on every bundle deploy. Removing the trigger does not recreate the existing run. diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index 072a48b26b9..49418540acf 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -845,10 +845,13 @@ resources.job_runs.*.jar_params[*] string ALL resources.job_runs.*.job_id int64 ALL resources.job_runs.*.job_parameters map[string]string ALL resources.job_runs.*.job_parameters.* string ALL +resources.job_runs.*.lifecycle *dresources.JobRunLifecycleState STATE resources.job_runs.*.lifecycle *resources.JobRunLifecycle INPUT resources.job_runs.*.lifecycle resources.Lifecycle INPUT resources.job_runs.*.lifecycle.prevent_destroy bool INPUT +resources.job_runs.*.lifecycle.triggers *dresources.JobRunTriggersState STATE resources.job_runs.*.lifecycle.triggers []resources.JobRunTrigger INPUT +resources.job_runs.*.lifecycle.triggers.on_bundle_deploy string STATE resources.job_runs.*.lifecycle.triggers[*] resources.JobRunTrigger INPUT resources.job_runs.*.lifecycle.triggers[*].on_bundle_deploy *bool INPUT resources.job_runs.*.modified_status string INPUT @@ -889,8 +892,6 @@ resources.job_runs.*.state.queue_reason string REMOTE resources.job_runs.*.state.result_state jobs.RunResultState REMOTE resources.job_runs.*.state.state_message string REMOTE resources.job_runs.*.state.user_cancelled_or_timedout bool REMOTE -resources.job_runs.*.triggers *dresources.JobRunTriggersState STATE -resources.job_runs.*.triggers.on_bundle_deploy string STATE resources.job_runs.*.url string INPUT resources.jobs.*.budget_policy_id string ALL resources.jobs.*.continuous *jobs.Continuous ALL diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt index 36dd5bad024..00bbd31f0d8 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt @@ -71,6 +71,18 @@ Resources: } } +=== removing on_bundle_deploy shows no drift +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_requests.py //jobs/run-now + >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: delete resources.job_runs.my_run diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/script b/acceptance/bundle/resources/job_runs/on_bundle_deploy/script index e482c1286e0..c3d634584e6 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/script +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/script @@ -20,3 +20,14 @@ trace $CLI bundle summary title "second run-now after recreate" trace print_requests.py --keep //jobs/runs/delete trace print_requests.py //jobs/run-now + +title "removing on_bundle_deploy shows no drift" +# Drop the lifecycle block so the next plan compares a nil fingerprint against +# the UUID left in state; that must not recreate. +update_file.py databricks.yml " lifecycle: + triggers: + - on_bundle_deploy: true +" "" +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests.py //jobs/run-now diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 653f3a0220b..08f50a01faf 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -28,10 +28,15 @@ const jobRunTimeout = 24 * time.Hour // JobRunTriggersState is the persisted fingerprint of lifecycle.triggers. type JobRunTriggersState struct { - // Fresh UUID each plan when on_bundle_deploy is set so Old!=New forces recreate. + // Fresh UUID each plan while armed so Old!=New forces recreate. OnBundleDeploy string `json:"on_bundle_deploy,omitempty"` } +// JobRunLifecycleState holds local-only lifecycle fields persisted in state. +type JobRunLifecycleState struct { + Triggers *JobRunTriggersState `json:"triggers,omitempty"` +} + // JobRunState is the RunNow request plus the outcome required for planning. type JobRunState struct { jobs.RunNow @@ -39,8 +44,9 @@ type JobRunState struct { // Always SUCCESS during planning and cleared before persistence. ResultState jobs.RunResultState `json:"result_state,omitempty"` - // Local-only trigger fingerprints; listed in knownMissingInRemoteType. - Triggers *JobRunTriggersState `json:"triggers,omitempty"` + // Local-only; listed in knownMissingInRemoteType. Nested under lifecycle to + // mirror config and avoid colliding with a future Jobs API field. + Lifecycle *JobRunLifecycleState `json:"lifecycle,omitempty"` } func (s *JobRunState) UnmarshalJSON(b []byte) error { @@ -91,10 +97,12 @@ func (*ResourceJobRun) PrepareState(input *resources.JobRun) *JobRunState { state := &JobRunState{ RunNow: input.RunNow, ResultState: jobs.RunResultStateSuccess, - Triggers: nil, + Lifecycle: nil, } if input.HasOnBundleDeploy() { - state.Triggers = &JobRunTriggersState{OnBundleDeploy: uuid.NewString()} + state.Lifecycle = &JobRunLifecycleState{ + Triggers: &JobRunTriggersState{OnBundleDeploy: uuid.NewString()}, + } } return state } @@ -175,8 +183,8 @@ func (*ResourceJobRun) RemapState(remote *JobRunRemote) *JobRunState { return &JobRunState{ RunNow: remote.RunNow, ResultState: remote.ResultState, - // Local-only trigger fingerprints stay unset on the remapped remote. - Triggers: nil, + // Local-only lifecycle fingerprints stay unset on the remapped remote. + Lifecycle: nil, } } @@ -366,14 +374,29 @@ func (r *ResourceJobRun) DoUpdate(ctx context.Context, id string, config *JobRun // still going, so a run that may yet succeed is adopted and waited on. A run that // stopped without succeeding keeps its recreate. A SKIPPED run reports no // result_state either, so the lifecycle state is what tells the two apart. +// Clearing the local-only trigger fingerprint is skipped so removing +// on_bundle_deploy does not recreate the run. func (*ResourceJobRun) OverrideChangeDesc(_ context.Context, path *structpath.PathNode, change *ChangeDesc, remote *JobRunRemote) error { - // The planner passes no remote state when the run could not be read. - if path.String() != "result_state" || remote == nil || runIsTerminal(remote.State.LifeCycleState) { + switch path.String() { + case "lifecycle", "lifecycle.triggers", "lifecycle.triggers.on_bundle_deploy": + // PrepareState nils Lifecycle when the trigger is unset; structdiff may + // report that at lifecycle, lifecycle.triggers, or the leaf. + if change.New == nil || change.New == "" { + change.Action = deployplan.Skip + change.Reason = "trigger removed" + } + return nil + case "result_state": + // The planner passes no remote state when the run could not be read. + if remote == nil || runIsTerminal(remote.State.LifeCycleState) { + return nil + } + change.Action = deployplan.Update + change.Reason = "run in progress" + return nil + default: return nil } - change.Action = deployplan.Update - change.Reason = "run in progress" - return nil } // DoDelete deletes the run via jobs/runs/delete, on both destroy and the diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index 34f1ede4d80..5efb6c1d2f3 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/libs/structs/structpath" "github.com/databricks/cli/libs/testserver" "github.com/databricks/databricks-sdk-go" @@ -345,7 +346,7 @@ func TestJobRunPrepareStateRequiresSuccess(t *testing.T) { func TestJobRunPrepareStateOnBundleDeploy(t *testing.T) { t.Run("unset", func(t *testing.T) { state := (&ResourceJobRun{}).PrepareState(&resources.JobRun{}) - assert.Nil(t, state.Triggers) + assert.Nil(t, state.Lifecycle) }) t.Run("armed", func(t *testing.T) { @@ -356,11 +357,48 @@ func TestJobRunPrepareStateOnBundleDeploy(t *testing.T) { }, } first := (&ResourceJobRun{}).PrepareState(input) - require.NotNil(t, first.Triggers) - assert.NotEmpty(t, first.Triggers.OnBundleDeploy) + require.NotNil(t, first.Lifecycle) + require.NotNil(t, first.Lifecycle.Triggers) + assert.NotEmpty(t, first.Lifecycle.Triggers.OnBundleDeploy) second := (&ResourceJobRun{}).PrepareState(input) - assert.NotEqual(t, first.Triggers.OnBundleDeploy, second.Triggers.OnBundleDeploy) + assert.NotEqual(t, first.Lifecycle.Triggers.OnBundleDeploy, second.Lifecycle.Triggers.OnBundleDeploy) + }) +} + +func TestJobRunOverrideChangeDescTriggerRemoved(t *testing.T) { + r := &ResourceJobRun{} + + t.Run("clearing lifecycle skips recreate", func(t *testing.T) { + change := &ChangeDesc{ + Action: deployplan.Recreate, + Old: &JobRunLifecycleState{Triggers: &JobRunTriggersState{OnBundleDeploy: "old"}}, + New: nil, + } + require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle"), change, nil)) + assert.Equal(t, deployplan.Skip, change.Action) + assert.Equal(t, "trigger removed", change.Reason) + }) + + t.Run("clearing on_bundle_deploy leaf skips recreate", func(t *testing.T) { + change := &ChangeDesc{ + Action: deployplan.Recreate, + Old: "old", + New: "", + } + require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle.triggers.on_bundle_deploy"), change, nil)) + assert.Equal(t, deployplan.Skip, change.Action) + assert.Equal(t, "trigger removed", change.Reason) + }) + + t.Run("fresh fingerprint still recreates", func(t *testing.T) { + change := &ChangeDesc{ + Action: deployplan.Recreate, + Old: "old", + New: "new", + } + require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle.triggers.on_bundle_deploy"), change, nil)) + assert.Equal(t, deployplan.Recreate, change.Action) }) } diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index 72d9c16ee8d..2d5516d59c7 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -57,8 +57,8 @@ var knownMissingInRemoteType = map[string][]string{ "usage_policy_id", }, "job_runs": { - // Local-only trigger fingerprints stored in state. - "triggers", + // Local-only trigger fingerprints under lifecycle. + "lifecycle", }, } @@ -86,6 +86,11 @@ var knownMissingInStateType = map[string][]string{ "sql_warehouses": { "lifecycle.prevent_destroy", }, + "job_runs": { + // State stores trigger fingerprints, not the config trigger list / prevent_destroy. + "lifecycle.prevent_destroy", + "lifecycle.triggers[*]", + }, "dashboards": { "file_path", }, From 1d3830adc59a84c4b7c93c7d1b370ef24f6a246e Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Mon, 17 Aug 2026 13:13:43 +0000 Subject: [PATCH 09/14] job_runs: refresh on_bundle_deploy golden for deploy summary Match the per-resource deploy/destroy output from #5720 after merging main. --- .../job_runs/on_bundle_deploy/output.txt | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt index 00bbd31f0d8..a6e538b30e6 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt @@ -2,11 +2,12 @@ === first deploy triggers a run >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default/files... -Deploying resources... job run [MY_RUN_ID]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID]?o=[NUMID] job run [MY_RUN_ID]: SUCCESS -Updating deployment state... -Deployment complete! +Created job_runs.my_run +Created jobs.my_job +Files: 5 uploaded, 0 deleted +Resources: 2 created, 0 changed, 0 deleted, 0 unchanged >>> read_id.py my_job [MY_JOB_ID] @@ -29,11 +30,11 @@ Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default/files... -Deploying resources... job run [MY_RUN_ID_2]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID_2]?o=[NUMID] job run [MY_RUN_ID_2]: SUCCESS -Updating deployment state... -Deployment complete! +Recreated job_runs.my_run +Files: 2 uploaded, 0 deleted +Resources: 1 created, 0 changed, 1 deleted, 1 unchanged >>> [CLI] bundle summary Name: job-runs-on-bundle-deploy @@ -77,9 +78,8 @@ Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default/files... -Deploying resources... -Updating deployment state... -Deployment complete! +Files: 3 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 2 unchanged >>> print_requests.py //jobs/run-now @@ -90,5 +90,4 @@ The following resources will be deleted: All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default -Deleting files... -Destroy complete! +Destroy: 2 deleted From 6c861939e339151b6d6e31ebada648a4614362ce Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Mon, 17 Aug 2026 17:26:36 +0000 Subject: [PATCH 10/14] chore: retrigger CI From 71143cb6851ac12d5cdc4f7a6e489bd7e2443620 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 18 Aug 2026 09:37:42 +0000 Subject: [PATCH 11/14] job_runs: cover on_bundle_deploy with a plan saved to disk Add a READPLAN variant so the fingerprint minted at plan time is exercised through plan serialization: a --plan deploy persists that fingerprint and still recreates the run, and a cleared fingerprint still plans no drift. Also hoist the direct-engine matrix and RecordRequests into parent test.toml files, since every job_runs test repeated them. Removing a test.toml drops one file from the synced bundle, hence the upload counts in three goldens. --- acceptance/bundle/resources/job_runs/basic/output.txt | 2 +- .../bundle/resources/job_runs/failed_run/test.toml | 4 ---- .../resources/job_runs/interrupted_run/test.toml | 5 ----- .../resources/job_runs/job_parameters/output.txt | 2 +- .../bundle/resources/job_runs/job_parameters/test.toml | 4 ---- .../resources/job_runs/on_bundle_deploy/out.test.toml | 1 + .../resources/job_runs/on_bundle_deploy/output.txt | 8 ++++---- .../bundle/resources/job_runs/on_bundle_deploy/script | 10 ++++++++-- .../resources/job_runs/on_bundle_deploy/test.toml | 9 ++++++--- .../bundle/resources/job_runs/redeploy/output.txt | 2 +- .../bundle/resources/job_runs/redeploy/test.toml | 4 ---- .../resources/job_runs/retried_run_now/test.toml | 6 ------ .../bundle/resources/job_runs/{basic => }/test.toml | 1 - acceptance/bundle/resources/job_runs/wait/test.toml | 4 ---- 14 files changed, 22 insertions(+), 40 deletions(-) delete mode 100644 acceptance/bundle/resources/job_runs/job_parameters/test.toml delete mode 100644 acceptance/bundle/resources/job_runs/redeploy/test.toml rename acceptance/bundle/resources/job_runs/{basic => }/test.toml (88%) diff --git a/acceptance/bundle/resources/job_runs/basic/output.txt b/acceptance/bundle/resources/job_runs/basic/output.txt index eb1b1461eda..95624f43a11 100644 --- a/acceptance/bundle/resources/job_runs/basic/output.txt +++ b/acceptance/bundle/resources/job_runs/basic/output.txt @@ -37,7 +37,7 @@ job run [MY_RUN_ID]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID] job run [MY_RUN_ID]: SUCCESS Created job_runs.my_run Created jobs.my_job -Files: 5 uploaded, 0 deleted +Files: 4 uploaded, 0 deleted Resources: 2 created, 0 changed, 0 deleted, 0 unchanged >>> [CLI] bundle deploy diff --git a/acceptance/bundle/resources/job_runs/failed_run/test.toml b/acceptance/bundle/resources/job_runs/failed_run/test.toml index 14707b0e51d..561602d5295 100644 --- a/acceptance/bundle/resources/job_runs/failed_run/test.toml +++ b/acceptance/bundle/resources/job_runs/failed_run/test.toml @@ -1,7 +1,3 @@ -# job_runs is a direct-engine-only resource; the Terraform provider has no -# equivalent, so restrict the matrix to direct. -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] - # Runs the failing job for real, so the message the deploy names the task with is # one a workspace reported. Serverless needs Unity Catalog. Cloud = true diff --git a/acceptance/bundle/resources/job_runs/interrupted_run/test.toml b/acceptance/bundle/resources/job_runs/interrupted_run/test.toml index 5e1d7da088b..dc6f4cafee7 100644 --- a/acceptance/bundle/resources/job_runs/interrupted_run/test.toml +++ b/acceptance/bundle/resources/job_runs/interrupted_run/test.toml @@ -1,8 +1,3 @@ -# job_runs is a direct-engine-only resource; the Terraform provider has no -# equivalent, so restrict the matrix to direct. -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] - # The interruption is staged by injecting a fault into the testserver, so this # stays off cloud. -RecordRequests = true Ignore = ["tmp.plan.json"] diff --git a/acceptance/bundle/resources/job_runs/job_parameters/output.txt b/acceptance/bundle/resources/job_runs/job_parameters/output.txt index 64016f7356b..ac37228ada8 100644 --- a/acceptance/bundle/resources/job_runs/job_parameters/output.txt +++ b/acceptance/bundle/resources/job_runs/job_parameters/output.txt @@ -6,7 +6,7 @@ job run [MY_RUN_ID]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID] job run [MY_RUN_ID]: SUCCESS Created job_runs.my_run Created jobs.my_job -Files: 5 uploaded, 0 deleted +Files: 4 uploaded, 0 deleted Resources: 2 created, 0 changed, 0 deleted, 0 unchanged >>> print_requests.py //jobs/run-now diff --git a/acceptance/bundle/resources/job_runs/job_parameters/test.toml b/acceptance/bundle/resources/job_runs/job_parameters/test.toml deleted file mode 100644 index 4b94d8b58e9..00000000000 --- a/acceptance/bundle/resources/job_runs/job_parameters/test.toml +++ /dev/null @@ -1,4 +0,0 @@ -# job_runs is a direct-engine-only resource; the Terraform provider has no -# equivalent, so restrict the matrix to direct. -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] -RecordRequests = true diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/out.test.toml b/acceptance/bundle/resources/job_runs/on_bundle_deploy/out.test.toml index 0938e678987..57b0f616850 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/out.test.toml +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/out.test.toml @@ -1,2 +1,3 @@ Cloud = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.READPLAN = ["", "1"] diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt index a6e538b30e6..5a67689ecd9 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt @@ -28,12 +28,12 @@ recreate job_runs.my_run Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged ->>> [CLI] bundle deploy +=== bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default/files... job run [MY_RUN_ID_2]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID_2]?o=[NUMID] job run [MY_RUN_ID_2]: SUCCESS Recreated job_runs.my_run -Files: 2 uploaded, 0 deleted +Files: 3 uploaded, 0 deleted Resources: 1 created, 0 changed, 1 deleted, 1 unchanged >>> [CLI] bundle summary @@ -76,9 +76,9 @@ Resources: >>> [CLI] bundle plan Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged ->>> [CLI] bundle deploy +=== bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default/files... -Files: 3 uploaded, 0 deleted +Files: 4 uploaded, 0 deleted Resources: 0 created, 0 changed, 0 deleted, 2 unchanged >>> print_requests.py //jobs/run-now diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/script b/acceptance/bundle/resources/job_runs/on_bundle_deploy/script index c3d634584e6..98546d1d698 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/script +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/script @@ -13,7 +13,11 @@ trace print_requests.py //jobs/run-now title "redeploy re-fires with unchanged config" trace $CLI bundle plan -trace $CLI bundle deploy +# Save the plan so the READPLAN=1 variant deploys the fingerprint minted here. +# The deploys are not traced: readplanarg makes the command line differ per variant. +$CLI bundle plan -o json > tmp.plan.json +title "bundle deploy\n" +$CLI bundle deploy $(readplanarg tmp.plan.json) read_id.py my_run > /dev/null trace $CLI bundle summary @@ -29,5 +33,7 @@ update_file.py databricks.yml " lifecycle: - on_bundle_deploy: true " "" trace $CLI bundle plan -trace $CLI bundle deploy +$CLI bundle plan -o json > tmp.plan.json +title "bundle deploy\n" +$CLI bundle deploy $(readplanarg tmp.plan.json) trace print_requests.py //jobs/run-now diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml b/acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml index e61157b3197..663d9510c10 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/test.toml @@ -1,3 +1,6 @@ -# Restrict the matrix to direct: job_runs is a direct-engine-only resource. -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] -RecordRequests = true +# Deploy both by re-planning and from a plan saved on disk, so the fingerprint +# minted during planning is exercised across plan serialization. +EnvMatrix.READPLAN = ["", "1"] + +# The saved plan embeds a fresh fingerprint on every run. +Ignore = ["tmp.plan.json"] diff --git a/acceptance/bundle/resources/job_runs/redeploy/output.txt b/acceptance/bundle/resources/job_runs/redeploy/output.txt index 7f3c1fcf664..e5d381769d0 100644 --- a/acceptance/bundle/resources/job_runs/redeploy/output.txt +++ b/acceptance/bundle/resources/job_runs/redeploy/output.txt @@ -6,7 +6,7 @@ job run [MY_RUN_ID]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID] job run [MY_RUN_ID]: SUCCESS Created job_runs.my_run Created jobs.my_job -Files: 5 uploaded, 0 deleted +Files: 4 uploaded, 0 deleted Resources: 2 created, 0 changed, 0 deleted, 0 unchanged >>> [CLI] bundle summary diff --git a/acceptance/bundle/resources/job_runs/redeploy/test.toml b/acceptance/bundle/resources/job_runs/redeploy/test.toml deleted file mode 100644 index 4b94d8b58e9..00000000000 --- a/acceptance/bundle/resources/job_runs/redeploy/test.toml +++ /dev/null @@ -1,4 +0,0 @@ -# job_runs is a direct-engine-only resource; the Terraform provider has no -# equivalent, so restrict the matrix to direct. -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] -RecordRequests = true diff --git a/acceptance/bundle/resources/job_runs/retried_run_now/test.toml b/acceptance/bundle/resources/job_runs/retried_run_now/test.toml index 3930a076f57..fce68392ea1 100644 --- a/acceptance/bundle/resources/job_runs/retried_run_now/test.toml +++ b/acceptance/bundle/resources/job_runs/retried_run_now/test.toml @@ -1,10 +1,4 @@ -# job_runs is a direct-engine-only resource; the Terraform provider has no -# equivalent, so restrict the matrix to direct. -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] - # Local only: the lost response is staged by injecting a fault into the testserver. -RecordRequests = true - Ignore = [".databricks"] # Number each distinct token so a reused token shows as [0] on both requests. diff --git a/acceptance/bundle/resources/job_runs/basic/test.toml b/acceptance/bundle/resources/job_runs/test.toml similarity index 88% rename from acceptance/bundle/resources/job_runs/basic/test.toml rename to acceptance/bundle/resources/job_runs/test.toml index 4b94d8b58e9..b056e544027 100644 --- a/acceptance/bundle/resources/job_runs/basic/test.toml +++ b/acceptance/bundle/resources/job_runs/test.toml @@ -1,4 +1,3 @@ # job_runs is a direct-engine-only resource; the Terraform provider has no # equivalent, so restrict the matrix to direct. EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] -RecordRequests = true diff --git a/acceptance/bundle/resources/job_runs/wait/test.toml b/acceptance/bundle/resources/job_runs/wait/test.toml index 5c84ac24640..989d6b2052d 100644 --- a/acceptance/bundle/resources/job_runs/wait/test.toml +++ b/acceptance/bundle/resources/job_runs/wait/test.toml @@ -1,7 +1,3 @@ -# job_runs is a direct-engine-only resource; the Terraform provider has no -# equivalent, so restrict the matrix to direct. -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] - # Runs the job for real on cloud. Serverless needs Unity Catalog. Cloud = true RequiresUnityCatalog = true From e5ce65782b402491ab2fa976f7cfccdf85ac20a7 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 18 Aug 2026 09:37:49 +0000 Subject: [PATCH 12/14] job_runs: drop unused JobRun.GetLifecycle Nothing reads the job_runs lifecycle through the interface: the only caller of GetLifecycle type-asserts to LifecycleWithStarted, and prevent_destroy is read from the dyn config in checkForPreventDestroy. Add it back together with the first generic consumer, such as lifecycle.depends_on. --- bundle/config/resources/job_run.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/bundle/config/resources/job_run.go b/bundle/config/resources/job_run.go index 1c648403de5..56f3c39a356 100644 --- a/bundle/config/resources/job_run.go +++ b/bundle/config/resources/job_run.go @@ -29,14 +29,6 @@ type JobRun struct { ResolvedJobID int64 `json:"resolved_job_id,omitempty" bundle:"internal"` } -// GetLifecycle returns the job_runs lifecycle, including triggers. -func (r *JobRun) GetLifecycle() LifecycleConfig { - if r.Lifecycle == nil { - return JobRunLifecycle{} - } - return *r.Lifecycle -} - // HasOnBundleDeploy reports whether any trigger re-fires on every deploy. func (r *JobRun) HasOnBundleDeploy() bool { if r.Lifecycle == nil { From c2f0507c445077f862a867e7af380f79ea4d39d4 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 18 Aug 2026 09:59:26 +0000 Subject: [PATCH 13/14] job_runs: clear removed trigger fingerprints with a state-only update Leaving the UUID in state via Skip works for on_bundle_deploy but would orphan meaningful hashes for later triggers; Update drops the fingerprint without re-firing the run. --- .../job_runs/on_bundle_deploy/output.txt | 9 +++++--- .../job_runs/on_bundle_deploy/script | 4 ++-- bundle/direct/dresources/job_run.go | 21 ++++++++++++------- bundle/direct/dresources/job_run_test.go | 8 +++---- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt index 5a67689ecd9..8865800d261 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt @@ -72,14 +72,17 @@ Resources: } } -=== removing on_bundle_deploy shows no drift +=== removing on_bundle_deploy rewrites state without a run >>> [CLI] bundle plan -Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged +update job_runs.my_run + +Plan: 0 to add, 1 to change, 0 to delete, 1 unchanged === bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default/files... +Updated job_runs.my_run Files: 4 uploaded, 0 deleted -Resources: 0 created, 0 changed, 0 deleted, 2 unchanged +Resources: 0 created, 1 changed, 0 deleted, 1 unchanged >>> print_requests.py //jobs/run-now diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/script b/acceptance/bundle/resources/job_runs/on_bundle_deploy/script index 98546d1d698..2c0f8d9c3a7 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/script +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/script @@ -25,9 +25,9 @@ title "second run-now after recreate" trace print_requests.py --keep //jobs/runs/delete trace print_requests.py //jobs/run-now -title "removing on_bundle_deploy shows no drift" +title "removing on_bundle_deploy rewrites state without a run" # Drop the lifecycle block so the next plan compares a nil fingerprint against -# the UUID left in state; that must not recreate. +# the UUID left in state: a state-only update that clears it, never a recreate. update_file.py databricks.yml " lifecycle: triggers: - on_bundle_deploy: true diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 08f50a01faf..c7487aa0810 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -363,8 +363,15 @@ func reportRunLine(ctx context.Context, runID int64, msg string) { } } -// DoUpdate finishes the wait an interrupted deploy abandoned. -func (r *ResourceJobRun) DoUpdate(ctx context.Context, id string, config *JobRunState, _ *PlanEntry) (*JobRunRemote, error) { +// DoUpdate rewrites state after a cleared trigger (no API call) or finishes the +// wait an interrupted deploy abandoned. +func (r *ResourceJobRun) DoUpdate(ctx context.Context, id string, config *JobRunState, entry *PlanEntry) (*JobRunRemote, error) { + // Clearing a trigger only drops its local-only fingerprint from state; wait on + // the run only when some other field changed. + if !entry.Changes.HasChangeExcept("lifecycle", "lifecycle.triggers", "lifecycle.triggers.on_bundle_deploy") { + config.ResultState = "" + return nil, nil + } remote, err := r.waitForRun(ctx, id) config.ResultState = "" return remote, err @@ -374,15 +381,15 @@ func (r *ResourceJobRun) DoUpdate(ctx context.Context, id string, config *JobRun // still going, so a run that may yet succeed is adopted and waited on. A run that // stopped without succeeding keeps its recreate. A SKIPPED run reports no // result_state either, so the lifecycle state is what tells the two apart. -// Clearing the local-only trigger fingerprint is skipped so removing -// on_bundle_deploy does not recreate the run. +// Clearing a trigger downgrades the recreate to a state-only update so the +// fingerprint is dropped from state without re-firing the run. func (*ResourceJobRun) OverrideChangeDesc(_ context.Context, path *structpath.PathNode, change *ChangeDesc, remote *JobRunRemote) error { switch path.String() { case "lifecycle", "lifecycle.triggers", "lifecycle.triggers.on_bundle_deploy": - // PrepareState nils Lifecycle when the trigger is unset; structdiff may - // report that at lifecycle, lifecycle.triggers, or the leaf. + // A cleared trigger sets New empty; structdiff may report it at lifecycle, + // lifecycle.triggers, or the leaf. DoUpdate treats these paths as no-ops. if change.New == nil || change.New == "" { - change.Action = deployplan.Skip + change.Action = deployplan.Update change.Reason = "trigger removed" } return nil diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index 5efb6c1d2f3..587ee594948 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -369,25 +369,25 @@ func TestJobRunPrepareStateOnBundleDeploy(t *testing.T) { func TestJobRunOverrideChangeDescTriggerRemoved(t *testing.T) { r := &ResourceJobRun{} - t.Run("clearing lifecycle skips recreate", func(t *testing.T) { + t.Run("clearing lifecycle downgrades to update", func(t *testing.T) { change := &ChangeDesc{ Action: deployplan.Recreate, Old: &JobRunLifecycleState{Triggers: &JobRunTriggersState{OnBundleDeploy: "old"}}, New: nil, } require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle"), change, nil)) - assert.Equal(t, deployplan.Skip, change.Action) + assert.Equal(t, deployplan.Update, change.Action) assert.Equal(t, "trigger removed", change.Reason) }) - t.Run("clearing on_bundle_deploy leaf skips recreate", func(t *testing.T) { + t.Run("clearing on_bundle_deploy leaf downgrades to update", func(t *testing.T) { change := &ChangeDesc{ Action: deployplan.Recreate, Old: "old", New: "", } require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle.triggers.on_bundle_deploy"), change, nil)) - assert.Equal(t, deployplan.Skip, change.Action) + assert.Equal(t, deployplan.Update, change.Action) assert.Equal(t, "trigger removed", change.Reason) }) From 8811a06509a634e495ca83f049707975f167faba Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 19 Aug 2026 08:56:04 +0000 Subject: [PATCH 14/14] job_runs: record on_bundle_deploy plan changes in acceptance Lock in the recreate vs state-only-update distinction from the serialized plan, not only the human-readable summary. --- .../job_runs/on_bundle_deploy/output.txt | 49 +++++++++++++++++++ .../job_runs/on_bundle_deploy/script | 2 + 2 files changed, 51 insertions(+) diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt index 8865800d261..2ed8e3bf01a 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt @@ -28,6 +28,36 @@ recreate job_runs.my_run Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged +>>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json +{ + "lifecycle": { + "action": "recreate", + "reason": "immutable", + "old": { + "triggers": { + "on_bundle_deploy": "[UUID]" + } + }, + "new": { + "triggers": { + "on_bundle_deploy": "[UUID]" + } + } + }, + "lifecycle.triggers.on_bundle_deploy": { + "action": "recreate", + "reason": "immutable", + "old": "[UUID]", + "new": "[UUID]" + }, + "result_state": { + "action": "skip", + "reason": "remote_already_set", + "new": "SUCCESS", + "remote": "SUCCESS" + } +} + === bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default/files... job run [MY_RUN_ID_2]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID_2]?o=[NUMID] @@ -78,6 +108,25 @@ update job_runs.my_run Plan: 0 to add, 1 to change, 0 to delete, 1 unchanged +>>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json +{ + "lifecycle": { + "action": "update", + "reason": "trigger removed", + "old": { + "triggers": { + "on_bundle_deploy": "[UUID]" + } + } + }, + "result_state": { + "action": "skip", + "reason": "remote_already_set", + "new": "SUCCESS", + "remote": "SUCCESS" + } +} + === bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-bundle-deploy/default/files... Updated job_runs.my_run diff --git a/acceptance/bundle/resources/job_runs/on_bundle_deploy/script b/acceptance/bundle/resources/job_runs/on_bundle_deploy/script index 2c0f8d9c3a7..e24cf944cd2 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/script +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/script @@ -16,6 +16,7 @@ trace $CLI bundle plan # Save the plan so the READPLAN=1 variant deploys the fingerprint minted here. # The deploys are not traced: readplanarg makes the command line differ per variant. $CLI bundle plan -o json > tmp.plan.json +trace jq '.plan["resources.job_runs.my_run"].changes' tmp.plan.json title "bundle deploy\n" $CLI bundle deploy $(readplanarg tmp.plan.json) read_id.py my_run > /dev/null @@ -34,6 +35,7 @@ update_file.py databricks.yml " lifecycle: " "" trace $CLI bundle plan $CLI bundle plan -o json > tmp.plan.json +trace jq '.plan["resources.job_runs.my_run"].changes' tmp.plan.json title "bundle deploy\n" $CLI bundle deploy $(readplanarg tmp.plan.json) trace print_requests.py //jobs/run-now