From 754f3913761ff63051b6d3748ffabdfee52c4ee8 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 11 Aug 2026 11:56:25 +0000 Subject: [PATCH 1/9] 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 | 48 ++++++++-- 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, 483 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 a71757ca6d2..e8fd8f4b7ce 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -20,17 +20,28 @@ import ( "github.com/databricks/databricks-sdk-go/marshal" "github.com/databricks/databricks-sdk-go/retries" "github.com/databricks/databricks-sdk-go/service/jobs" + "github.com/google/uuid" ) // 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 { @@ -78,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 @@ -157,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) { @@ -342,14 +363,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 4138ab18a66..14c6c8b540f 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -341,6 +341,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 6f0327a3342..3838713d4c7 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -51,6 +51,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 c0eea2abf38..100e210a3cb 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -973,7 +973,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 0af9394f243..253848727ed 100644 --- a/bundle/phases/plan.go +++ b/bundle/phases/plan.go @@ -26,6 +26,7 @@ func PreDeployChecks(ctx context.Context, b *bundle.Bundle, isPlan bool, engine mutator.ValidateGitDetails(), mutator.ValidateDirectOnlyResources(engine), mutator.ValidateLifecycleStarted(engine), + mutator.ValidateJobRunTriggers(), statemgmt.CheckRunningResource(engine), ) } diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 988a91e4fb3..e9ada12c0c0 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": [ { @@ -15161,6 +15209,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 e3e1bfc10a50a6040e0ab261cfa62702103e8991 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 11 Aug 2026 12:34:17 +0000 Subject: [PATCH 2/9] 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 100e210a3cb..07fdd3daeda 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -985,12 +985,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 e9ada12c0c0..1486c55458f 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 c753f0861bcd9498efea9309842d8ca647594491 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 11 Aug 2026 12:39:24 +0000 Subject: [PATCH 3/9] 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 e8fd8f4b7ce..dfc0d085296 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, } } @@ -363,8 +362,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 3838713d4c7..e8378046346 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -52,7 +52,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 07fdd3daeda..7e9d7143715 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -980,11 +980,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 1486c55458f..21810377f04 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 ab78519180617adddef55facacba8e8810f55793 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 11 Aug 2026 13:11:42 +0000 Subject: [PATCH 4/9] 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 91f616b88611d5afcef6a2727f9cc49c99728209 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 11 Aug 2026 13:18:20 +0000 Subject: [PATCH 5/9] 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 cbeb07f743c..9288872dcc1 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -984,7 +984,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 9856fc8e18c..1ceede2ee68 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1294,7 +1294,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 c55bd7da7f56b44eac1484c5074573a8ba7eefbd Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 11 Aug 2026 13:32:33 +0000 Subject: [PATCH 6/9] 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 27cd4797a6d..c64592be0dd 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 2d96f50b9c562a16e86100b4d30446dff9bc1f52 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 11 Aug 2026 14:32:04 +0000 Subject: [PATCH 7/9] 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 +++ bundle/phases/plan.go | 1 - 3 files changed, 4 insertions(+), 2 deletions(-) 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. diff --git a/bundle/phases/plan.go b/bundle/phases/plan.go index 4c871a5d441..aee481b2b14 100644 --- a/bundle/phases/plan.go +++ b/bundle/phases/plan.go @@ -28,7 +28,6 @@ func PreDeployChecks(ctx context.Context, b *bundle.Bundle, isPlan bool, engine mutator.ValidateGitDetails(), mutator.ValidateDirectOnlyResources(engine), mutator.ValidateLifecycleStarted(engine), - mutator.ValidateJobRunTriggers(), mutator.ValidateCascadeOnDestroy(engine), statemgmt.CheckRunningResource(engine), ) From ac36a8e73a0ff4c6700e344fb9de8cc0b634f659 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Mon, 17 Aug 2026 12:23:16 +0000 Subject: [PATCH 8/9] 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 c64592be0dd..d1035936fc0 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 b1eb64e70eb1e1c09950f149d7f41a2815f8f05f Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Mon, 17 Aug 2026 13:13:43 +0000 Subject: [PATCH 9/9] 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