From be0eca098ce840163341b3d3a832d64e4af13b68 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 18 Aug 2026 12:25:26 +0000 Subject: [PATCH 01/16] job_runs: add lifecycle.triggers.on_file_change Re-fire a run when matched file contents change, using stable hash fingerprints so mtime-only updates (touch) do not recreate. --- .../bundles/job-runs-on-file-change.md | 1 + .../job_runs/on_file_change/databricks.yml | 18 ++ .../job_runs/on_file_change/out.test.toml | 2 + .../job_runs/on_file_change/output.txt | 90 +++++++++ .../resources/job_runs/on_file_change/script | 34 ++++ .../job_runs/on_file_change/seed.txt | 1 + .../mutator/resolve_job_run_file_triggers.go | 177 ++++++++++++++++++ ...lve_job_run_file_triggers_internal_test.go | 73 ++++++++ .../resolve_job_run_file_triggers_test.go | 143 ++++++++++++++ .../mutator/validate_job_run_triggers.go | 26 ++- .../mutator/validate_job_run_triggers_test.go | 50 ++++- bundle/config/resources/job_run.go | 29 +++ bundle/config/resources/lifecycle.go | 9 +- bundle/direct/dresources/job_run.go | 31 ++- bundle/direct/dresources/job_run_test.go | 76 ++++++++ bundle/direct/dresources/resources.yml | 6 + bundle/internal/schema/annotations.yml | 3 + bundle/phases/initialize.go | 6 +- bundle/phases/plan.go | 4 +- bundle/schema/jsonschema.json | 4 + 20 files changed, 767 insertions(+), 16 deletions(-) create mode 100644 .nextchanges/bundles/job-runs-on-file-change.md create mode 100644 acceptance/bundle/resources/job_runs/on_file_change/databricks.yml create mode 100644 acceptance/bundle/resources/job_runs/on_file_change/out.test.toml create mode 100644 acceptance/bundle/resources/job_runs/on_file_change/output.txt create mode 100644 acceptance/bundle/resources/job_runs/on_file_change/script create mode 100644 acceptance/bundle/resources/job_runs/on_file_change/seed.txt create mode 100644 bundle/config/mutator/resolve_job_run_file_triggers.go create mode 100644 bundle/config/mutator/resolve_job_run_file_triggers_internal_test.go create mode 100644 bundle/config/mutator/resolve_job_run_file_triggers_test.go diff --git a/.nextchanges/bundles/job-runs-on-file-change.md b/.nextchanges/bundles/job-runs-on-file-change.md new file mode 100644 index 00000000000..00682403a0d --- /dev/null +++ b/.nextchanges/bundles/job-runs-on-file-change.md @@ -0,0 +1 @@ +direct: `resources.job_runs` can set `lifecycle.triggers.on_file_change` to a path or glob to re-fire the run when matched file contents change. Removing the trigger does not recreate the existing run. diff --git a/acceptance/bundle/resources/job_runs/on_file_change/databricks.yml b/acceptance/bundle/resources/job_runs/on_file_change/databricks.yml new file mode 100644 index 00000000000..0a6df75047a --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change/databricks.yml @@ -0,0 +1,18 @@ +bundle: + name: job-runs-on-file-change + +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_file_change: seed.txt diff --git a/acceptance/bundle/resources/job_runs/on_file_change/out.test.toml b/acceptance/bundle/resources/job_runs/on_file_change/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/on_file_change/output.txt b/acceptance/bundle/resources/job_runs/on_file_change/output.txt new file mode 100644 index 00000000000..bcf5962f8fa --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change/output.txt @@ -0,0 +1,90 @@ + +=== first deploy triggers a run +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-file-change/default/files... +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 +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] + +>>> print_requests.py //jobs/run-now +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[UUID]", + "job_id": [MY_JOB_ID] + } +} + +=== redeploy with unchanged file plans nothing +>>> [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-file-change/default/files... +Files: 2 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 2 unchanged + +>>> print_requests.py //jobs/run-now + +=== editing the file 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-file-change/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: 3 uploaded, 0 deleted +Resources: 1 created, 0 changed, 1 deleted, 1 unchanged + +>>> 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": { + "idempotency_token": "[UUID]", + "job_id": [MY_JOB_ID] + } +} + +=== removing on_file_change rewrites state without a run +>>> [CLI] bundle plan +update job_runs.my_run + +Plan: 0 to add, 1 to change, 0 to delete, 1 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-file-change/default/files... +Updated job_runs.my_run +Files: 3 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 1 unchanged + +>>> print_requests.py //jobs/run-now + +>>> [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-file-change/default + +Destroy: 2 deleted diff --git a/acceptance/bundle/resources/job_runs/on_file_change/script b/acceptance/bundle/resources/job_runs/on_file_change/script new file mode 100644 index 00000000000..ff162e11f0b --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change/script @@ -0,0 +1,34 @@ +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 recreated one becomes [MY_RUN_ID_2]. +read_id.py my_run > /dev/null +trace print_requests.py //jobs/run-now + +title "redeploy with unchanged file plans nothing" +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests.py //jobs/run-now + +title "editing the file re-fires" +update_file.py seed.txt "v1" "v2" +trace $CLI bundle plan +trace $CLI bundle deploy +read_id.py my_run > /dev/null +trace print_requests.py --keep //jobs/runs/delete +trace print_requests.py //jobs/run-now + +title "removing on_file_change rewrites state without a run" +update_file.py databricks.yml " lifecycle: + triggers: + - on_file_change: seed.txt +" "" +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests.py //jobs/run-now diff --git a/acceptance/bundle/resources/job_runs/on_file_change/seed.txt b/acceptance/bundle/resources/job_runs/on_file_change/seed.txt new file mode 100644 index 00000000000..626799f0f85 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change/seed.txt @@ -0,0 +1 @@ +v1 diff --git a/bundle/config/mutator/resolve_job_run_file_triggers.go b/bundle/config/mutator/resolve_job_run_file_triggers.go new file mode 100644 index 00000000000..a82ed654be3 --- /dev/null +++ b/bundle/config/mutator/resolve_job_run_file_triggers.go @@ -0,0 +1,177 @@ +package mutator + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/libs/diag" +) + +// missingFileSentinelSize marks a pattern with no matching file so the next +// plan can distinguish "still missing" from "file appeared". +const missingFileSentinelSize = int64(-1) + +type resolveJobRunFileTriggers struct{} + +// ResolveJobRunFileTriggers expands on_file_change globs and stores per-file +// fingerprints on each job_run for PrepareState to copy into local state. +func ResolveJobRunFileTriggers() bundle.Mutator { + return &resolveJobRunFileTriggers{} +} + +func (*resolveJobRunFileTriggers) Name() string { + return "ResolveJobRunFileTriggers" +} + +func (*resolveJobRunFileTriggers) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnostics { + var diags diag.Diagnostics + for name, jr := range b.Config.Resources.JobRuns { + if jr == nil { + continue + } + patterns := jr.OnFileChangePatterns() + if len(patterns) == 0 { + continue + } + fps, d := resolveFileTriggers(b, name, patterns, previousFileTriggers(b, name)) + diags = diags.Extend(d) + jr.ResolvedFileTriggers = fps + } + return diags +} + +// previousFileTriggers reads on_file_change fingerprints from deployment state +// when it is open (plan/deploy after StatePull). Used so unchanged content keeps +// a stable fingerprint across mtime-only updates (e.g. touch). +func previousFileTriggers(b *bundle.Bundle, name string) map[string]resources.JobRunFileFingerprint { + if b.DeploymentBundle.StateDB.Path == "" { + return nil + } + entry, ok := b.DeploymentBundle.StateDB.GetResourceEntry("resources.job_runs." + name) + if !ok || len(entry.State) == 0 { + return nil + } + var state struct { + Lifecycle *struct { + Triggers *struct { + OnFileChange map[string]resources.JobRunFileFingerprint `json:"on_file_change"` + } `json:"triggers"` + } `json:"lifecycle"` + } + if err := json.Unmarshal(entry.State, &state); err != nil { + return nil + } + if state.Lifecycle == nil || state.Lifecycle.Triggers == nil { + return nil + } + return state.Lifecycle.Triggers.OnFileChange +} + +func resolveFileTriggers(b *bundle.Bundle, name string, patterns []string, prev map[string]resources.JobRunFileFingerprint) (map[string]resources.JobRunFileFingerprint, diag.Diagnostics) { + var diags diag.Diagnostics + out := make(map[string]resources.JobRunFileFingerprint) + for _, pattern := range patterns { + path := fmt.Sprintf("resources.job_runs.%s.lifecycle.triggers", name) + matches, err := filepath.Glob(filepath.Join(b.SyncRootPath, filepath.FromSlash(pattern))) + if err != nil { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: invalid pattern %q: %s", pattern, err), + Locations: b.Config.GetLocations(path), + }) + continue + } + if len(matches) == 0 { + // Distinct state when the path/glob matches nothing (design doc). + out[filepath.ToSlash(pattern)] = resources.JobRunFileFingerprint{ + Size: missingFileSentinelSize, + } + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Warning, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: no files match %q", pattern), + Locations: b.Config.GetLocations(path), + }) + continue + } + for _, match := range matches { + info, err := os.Stat(match) + if err != nil { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: stat %q: %s", match, err), + Locations: b.Config.GetLocations(path), + }) + continue + } + if !info.Mode().IsRegular() { + continue + } + rel, err := filepath.Rel(b.SyncRootPath, match) + if err != nil { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: relative path for %q: %s", match, err), + Locations: b.Config.GetLocations(path), + }) + continue + } + key := filepath.ToSlash(rel) + fp, err := fingerprintFile(match, info, prev[key]) + if err != nil { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: hash %q: %s", match, err), + Locations: b.Config.GetLocations(path), + }) + continue + } + out[key] = fp + } + } + return out, diags +} + +// fingerprintFile builds a content fingerprint. If prev has the same size and +// mtime, it is reused without reading the file. If content hash matches prev, +// prev is reused so mtime-only updates (touch) do not change planned state. +func fingerprintFile(path string, info os.FileInfo, prev resources.JobRunFileFingerprint) (resources.JobRunFileFingerprint, error) { + size := info.Size() + mtime := info.ModTime().UnixNano() + if prev.Hash != "" && prev.Size == size && prev.MtimeNs == mtime { + return prev, nil + } + hash, err := hashFile(path) + if err != nil { + return resources.JobRunFileFingerprint{}, err + } + if prev.Hash != "" && prev.Hash == hash { + return prev, nil + } + return resources.JobRunFileFingerprint{ + Hash: hash, + Size: size, + MtimeNs: mtime, + }, nil +} + +func hashFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/bundle/config/mutator/resolve_job_run_file_triggers_internal_test.go b/bundle/config/mutator/resolve_job_run_file_triggers_internal_test.go new file mode 100644 index 00000000000..e82bd22945b --- /dev/null +++ b/bundle/config/mutator/resolve_job_run_file_triggers_internal_test.go @@ -0,0 +1,73 @@ +package mutator + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/databricks/cli/bundle/config/resources" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFingerprintFileContentStableAcrossTouch(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "seed.txt") + require.NoError(t, os.WriteFile(path, []byte("v1"), 0o644)) + + info, err := os.Stat(path) + require.NoError(t, err) + first, err := fingerprintFile(path, info, resources.JobRunFileFingerprint{}) + require.NoError(t, err) + require.NotEmpty(t, first.Hash) + + // Advance mtime without changing contents (touch). + require.NoError(t, os.Chtimes(path, time.Now().Add(time.Minute), time.Now().Add(time.Minute))) + info, err = os.Stat(path) + require.NoError(t, err) + assert.NotEqual(t, first.MtimeNs, info.ModTime().UnixNano()) + + second, err := fingerprintFile(path, info, first) + require.NoError(t, err) + assert.Equal(t, first, second, "unchanged content must reuse the previous fingerprint") +} + +func TestFingerprintFileFastPathSkipsWhenMtimeAndSizeMatch(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "seed.txt") + require.NoError(t, os.WriteFile(path, []byte("v1"), 0o644)) + + info, err := os.Stat(path) + require.NoError(t, err) + prev := resources.JobRunFileFingerprint{ + Hash: "not-the-real-hash", + Size: info.Size(), + MtimeNs: info.ModTime().UnixNano(), + } + + got, err := fingerprintFile(path, info, prev) + require.NoError(t, err) + assert.Equal(t, prev, got, "matching size+mtime must reuse prev without re-hashing") +} + +func TestFingerprintFileContentChange(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "seed.txt") + require.NoError(t, os.WriteFile(path, []byte("v1"), 0o644)) + + info, err := os.Stat(path) + require.NoError(t, err) + first, err := fingerprintFile(path, info, resources.JobRunFileFingerprint{}) + require.NoError(t, err) + + // Different size so the mtime+size fast path cannot reuse prev. + require.NoError(t, os.WriteFile(path, []byte("v2-changed"), 0o644)) + info, err = os.Stat(path) + require.NoError(t, err) + second, err := fingerprintFile(path, info, first) + require.NoError(t, err) + assert.NotEqual(t, first.Hash, second.Hash) + assert.Equal(t, info.Size(), second.Size) + assert.Equal(t, info.ModTime().UnixNano(), second.MtimeNs) +} diff --git a/bundle/config/mutator/resolve_job_run_file_triggers_test.go b/bundle/config/mutator/resolve_job_run_file_triggers_test.go new file mode 100644 index 00000000000..9a9cf77bf53 --- /dev/null +++ b/bundle/config/mutator/resolve_job_run_file_triggers_test.go @@ -0,0 +1,143 @@ +package mutator_test + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "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/databricks/cli/libs/diag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveJobRunFileTriggers(t *testing.T) { + t.Run("matches files and fills fingerprints", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), []byte("world"), 0o644)) + + pattern := "*.txt" + b := bundleWithFileTrigger(dir, pattern) + + diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) + require.False(t, diags.HasError()) + + fps := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers + require.Len(t, fps, 2) + + assertFingerprint(t, fps["a.txt"], "hello") + assertFingerprint(t, fps["b.txt"], "world") + }) + + t.Run("no matches warns and stores sentinel", func(t *testing.T) { + dir := t.TempDir() + pattern := "missing.txt" + b := bundleWithFileTrigger(dir, pattern) + + diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) + require.False(t, diags.HasError()) + require.Len(t, diags, 1) + assert.Equal(t, diag.Warning, diags[0].Severity) + assert.Contains(t, diags[0].Summary, `no files match "missing.txt"`) + + fps := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers + require.Len(t, fps, 1) + fp := fps["missing.txt"] + assert.Empty(t, fp.Hash) + assert.Equal(t, int64(-1), fp.Size) + assert.Zero(t, fp.MtimeNs) + }) + + t.Run("no file triggers is a no-op", func(t *testing.T) { + dir := t.TempDir() + on := true + b := &bundle.Bundle{ + SyncRootPath: dir, + Config: config.Root{ + Resources: config.Resources{ + JobRuns: map[string]*resources.JobRun{ + "my_run": { + Lifecycle: &resources.JobRunLifecycle{ + Triggers: []resources.JobRunTrigger{ + {OnBundleDeploy: &on}, + }, + }, + }, + }, + }, + }, + } + + diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) + assert.Empty(t, diags) + assert.Nil(t, b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers) + }) + + t.Run("multiple patterns merge into one map", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("aaa"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "subdir"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "subdir", "x.py"), []byte("bbb"), 0o644)) + + patA := "a.txt" + patB := "subdir/*.py" + b := &bundle.Bundle{ + SyncRootPath: dir, + Config: config.Root{ + Resources: config.Resources{ + JobRuns: map[string]*resources.JobRun{ + "my_run": { + Lifecycle: &resources.JobRunLifecycle{ + Triggers: []resources.JobRunTrigger{ + {OnFileChange: &patA}, + {OnFileChange: &patB}, + }, + }, + }, + }, + }, + }, + } + + diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) + require.False(t, diags.HasError()) + + fps := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers + require.Len(t, fps, 2) + assertFingerprint(t, fps["a.txt"], "aaa") + assertFingerprint(t, fps["subdir/x.py"], "bbb") + }) +} + +func bundleWithFileTrigger(syncRoot, pattern string) *bundle.Bundle { + return &bundle.Bundle{ + SyncRootPath: syncRoot, + Config: config.Root{ + Resources: config.Resources{ + JobRuns: map[string]*resources.JobRun{ + "my_run": { + Lifecycle: &resources.JobRunLifecycle{ + Triggers: []resources.JobRunTrigger{ + {OnFileChange: &pattern}, + }, + }, + }, + }, + }, + }, + } +} + +func assertFingerprint(t *testing.T, fp resources.JobRunFileFingerprint, content string) { + t.Helper() + sum := sha256.Sum256([]byte(content)) + assert.Equal(t, hex.EncodeToString(sum[:]), fp.Hash) + assert.Equal(t, int64(len(content)), fp.Size) + assert.NotZero(t, fp.MtimeNs) +} diff --git a/bundle/config/mutator/validate_job_run_triggers.go b/bundle/config/mutator/validate_job_run_triggers.go index a7c5ada925e..c185d3b5781 100644 --- a/bundle/config/mutator/validate_job_run_triggers.go +++ b/bundle/config/mutator/validate_job_run_triggers.go @@ -3,6 +3,7 @@ package mutator import ( "context" "fmt" + "strings" "github.com/databricks/cli/bundle" "github.com/databricks/cli/libs/diag" @@ -26,30 +27,45 @@ func (*validateJobRunTriggers) Apply(_ context.Context, b *bundle.Bundle) diag.D continue } // Recreate-every-deploy cannot coexist with prevent_destroy. - if jr.HasOnBundleDeploy() && jr.Lifecycle.PreventDestroy { + if (jr.HasOnBundleDeploy() || jr.HasOnFileChange()) && jr.Lifecycle.PreventDestroy { diags = diags.Append(diag.Diagnostic{ Severity: diag.Error, - Summary: "lifecycle.triggers.on_bundle_deploy is incompatible with lifecycle.prevent_destroy", + Summary: "lifecycle.triggers.on_bundle_deploy or on_file_change 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 { + if t.OnBundleDeploy == nil && t.OnFileChange == nil { diags = diags.Append(diag.Diagnostic{ Severity: diag.Error, - Summary: "lifecycle.triggers entry must set on_bundle_deploy: true", + Summary: "lifecycle.triggers entry must set on_bundle_deploy or on_file_change", Locations: b.Config.GetLocations(path), }) continue } - if !*t.OnBundleDeploy { + if t.OnBundleDeploy != nil && t.OnFileChange != nil { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: "lifecycle.triggers entry must set only one of on_bundle_deploy or on_file_change", + Locations: b.Config.GetLocations(path), + }) + continue + } + 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 != nil && strings.TrimSpace(*t.OnFileChange) == "" { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: "lifecycle.triggers.on_file_change must be non-empty when set", + Locations: b.Config.GetLocations(path + ".on_file_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 9c225457471..7e8772d8d8b 100644 --- a/bundle/config/mutator/validate_job_run_triggers_test.go +++ b/bundle/config/mutator/validate_job_run_triggers_test.go @@ -14,6 +14,10 @@ func TestValidateJobRunTriggers(t *testing.T) { trueVal := true falseVal := false + fileChange := "seed.txt" + emptyFile := "" + whitespaceFile := " \t" + tests := []struct { name string triggers []resources.JobRunTrigger @@ -26,12 +30,32 @@ func TestValidateJobRunTriggers(t *testing.T) { {OnBundleDeploy: &trueVal}, }, }, + { + name: "on_file_change set", + triggers: []resources.JobRunTrigger{ + {OnFileChange: &fileChange}, + }, + }, + { + name: "both triggers as separate entries", + triggers: []resources.JobRunTrigger{ + {OnFileChange: &fileChange}, + {OnBundleDeploy: &trueVal}, + }, + }, { name: "empty entry", triggers: []resources.JobRunTrigger{ {}, }, - summary: "lifecycle.triggers entry must set on_bundle_deploy: true", + summary: "lifecycle.triggers entry must set on_bundle_deploy or on_file_change", + }, + { + name: "both keys on one entry", + triggers: []resources.JobRunTrigger{ + {OnBundleDeploy: &trueVal, OnFileChange: &fileChange}, + }, + summary: "lifecycle.triggers entry must set only one of on_bundle_deploy or on_file_change", }, { name: "on_bundle_deploy false", @@ -40,13 +64,35 @@ func TestValidateJobRunTriggers(t *testing.T) { }, summary: "lifecycle.triggers.on_bundle_deploy must be true when set", }, + { + name: "on_file_change empty", + triggers: []resources.JobRunTrigger{ + {OnFileChange: &emptyFile}, + }, + summary: "lifecycle.triggers.on_file_change must be non-empty when set", + }, + { + name: "on_file_change whitespace", + triggers: []resources.JobRunTrigger{ + {OnFileChange: &whitespaceFile}, + }, + summary: "lifecycle.triggers.on_file_change must be non-empty 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", + summary: "lifecycle.triggers.on_bundle_deploy or on_file_change is incompatible with lifecycle.prevent_destroy", + }, + { + name: "on_file_change with prevent_destroy", + triggers: []resources.JobRunTrigger{ + {OnFileChange: &fileChange}, + }, + preventDestroy: true, + summary: "lifecycle.triggers.on_bundle_deploy or on_file_change is incompatible with lifecycle.prevent_destroy", }, { name: "prevent_destroy alone", diff --git a/bundle/config/resources/job_run.go b/bundle/config/resources/job_run.go index 56f3c39a356..cf66c71f728 100644 --- a/bundle/config/resources/job_run.go +++ b/bundle/config/resources/job_run.go @@ -27,6 +27,10 @@ type JobRun struct { // 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"` + + // ResolvedFileTriggers is the per-file fingerprint map for on_file_change, + // computed under SyncRoot before plan. bundle:"internal" keeps it out of schema. + ResolvedFileTriggers map[string]JobRunFileFingerprint `json:"resolved_file_triggers,omitempty" bundle:"internal"` } // HasOnBundleDeploy reports whether any trigger re-fires on every deploy. @@ -42,6 +46,31 @@ func (r *JobRun) HasOnBundleDeploy() bool { return false } +func (r *JobRun) HasOnFileChange() bool { + if r.Lifecycle == nil { + return false + } + for _, t := range r.Lifecycle.Triggers { + if t.OnFileChange != nil { + return true + } + } + return false +} + +func (r *JobRun) OnFileChangePatterns() []string { + if r.Lifecycle == nil { + return nil + } + var patterns []string + for _, t := range r.Lifecycle.Triggers { + if t.OnFileChange != nil { + patterns = append(patterns, *t.OnFileChange) + } + } + return patterns +} + 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 88d130239ee..59ccb682b81 100644 --- a/bundle/config/resources/lifecycle.go +++ b/bundle/config/resources/lifecycle.go @@ -36,5 +36,12 @@ type JobRunLifecycle struct { // JobRunTrigger is one lifecycle.triggers entry. type JobRunTrigger struct { - OnBundleDeploy *bool `json:"on_bundle_deploy,omitempty"` + OnBundleDeploy *bool `json:"on_bundle_deploy,omitempty"` + OnFileChange *string `json:"on_file_change,omitempty"` // path or glob under sync root +} + +type JobRunFileFingerprint struct { + Hash string `json:"hash"` + Size int64 `json:"size"` + MtimeNs int64 `json:"mtime_ns"` } diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index b391fd706d4..219cf2ab740 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -26,10 +26,21 @@ import ( // jobRunTimeout matches the timeout `bundle run` allows a run (bundle/run/job.go). const jobRunTimeout = 24 * time.Hour +// jobRunTriggerLocalPaths is shared by OverrideChangeDesc and DoUpdate so +// clearing a trigger stays a state-only update in both places. +var jobRunTriggerLocalPaths = []string{ + "lifecycle", + "lifecycle.triggers", + "lifecycle.triggers.on_bundle_deploy", + "lifecycle.triggers.on_file_change", +} + // JobRunTriggersState is the persisted fingerprint of lifecycle.triggers. type JobRunTriggersState struct { // Fresh UUID each plan while armed so Old!=New forces recreate. OnBundleDeploy string `json:"on_bundle_deploy,omitempty"` + // Per-file fingerprints from ResolveJobRunFileTriggers; change to recreate. + OnFileChange map[string]resources.JobRunFileFingerprint `json:"on_file_change,omitempty"` } // JobRunLifecycleState holds local-only lifecycle fields persisted in state. @@ -97,12 +108,19 @@ func (*ResourceJobRun) PrepareState(input *resources.JobRun) *JobRunState { state := &JobRunState{ RunNow: input.RunNow, ResultState: jobs.RunResultStateSuccess, - Lifecycle: nil, } + var triggers *JobRunTriggersState if input.HasOnBundleDeploy() { - state.Lifecycle = &JobRunLifecycleState{ - Triggers: &JobRunTriggersState{OnBundleDeploy: uuid.NewString()}, + triggers = &JobRunTriggersState{OnBundleDeploy: uuid.NewString()} + } + if len(input.ResolvedFileTriggers) > 0 { + if triggers == nil { + triggers = &JobRunTriggersState{} } + triggers.OnFileChange = input.ResolvedFileTriggers + } + if triggers != nil { + state.Lifecycle = &JobRunLifecycleState{Triggers: triggers} } return state } @@ -370,7 +388,7 @@ func reportRunLine(ctx context.Context, runID int64, msg string) { 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") { + if !entry.Changes.HasChangeExcept(jobRunTriggerLocalPaths...) { config.ResultState = "" return nil, nil } @@ -386,8 +404,7 @@ func (r *ResourceJobRun) DoUpdate(ctx context.Context, id string, config *JobRun // 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": + if slices.Contains(jobRunTriggerLocalPaths, path.String()) { // 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 == "" { @@ -395,6 +412,8 @@ func (*ResourceJobRun) OverrideChangeDesc(_ context.Context, path *structpath.Pa change.Reason = "trigger removed" } return nil + } + switch path.String() { case "result_state": // The planner passes no remote state when the run could not be read. if remote == nil || runIsTerminal(remote.State.LifeCycleState) { diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index 27c1b93fac1..e374e7b0130 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -376,6 +376,41 @@ func TestJobRunPrepareStateOnBundleDeploy(t *testing.T) { }) } +func TestJobRunPrepareStateOnFileChange(t *testing.T) { + fps := map[string]resources.JobRunFileFingerprint{ + "a.txt": {Hash: "abc", Size: 3, MtimeNs: 1}, + } + + t.Run("unset", func(t *testing.T) { + state := (&ResourceJobRun{}).PrepareState(&resources.JobRun{}) + assert.Nil(t, state.Lifecycle) + }) + + t.Run("armed", func(t *testing.T) { + state := (&ResourceJobRun{}).PrepareState(&resources.JobRun{ + ResolvedFileTriggers: fps, + }) + require.NotNil(t, state.Lifecycle) + require.NotNil(t, state.Lifecycle.Triggers) + assert.Equal(t, fps, state.Lifecycle.Triggers.OnFileChange) + assert.Empty(t, state.Lifecycle.Triggers.OnBundleDeploy) + }) + + t.Run("both triggers", func(t *testing.T) { + on := true + state := (&ResourceJobRun{}).PrepareState(&resources.JobRun{ + Lifecycle: &resources.JobRunLifecycle{ + Triggers: []resources.JobRunTrigger{{OnBundleDeploy: &on}}, + }, + ResolvedFileTriggers: fps, + }) + require.NotNil(t, state.Lifecycle) + require.NotNil(t, state.Lifecycle.Triggers) + assert.NotEmpty(t, state.Lifecycle.Triggers.OnBundleDeploy) + assert.Equal(t, fps, state.Lifecycle.Triggers.OnFileChange) + }) +} + func TestJobRunOverrideChangeDescTriggerRemoved(t *testing.T) { r := &ResourceJobRun{} @@ -401,6 +436,19 @@ func TestJobRunOverrideChangeDescTriggerRemoved(t *testing.T) { assert.Equal(t, "trigger removed", change.Reason) }) + t.Run("clearing on_file_change leaf downgrades to update", func(t *testing.T) { + change := &ChangeDesc{ + Action: deployplan.Recreate, + Old: map[string]resources.JobRunFileFingerprint{ + "a.txt": {Hash: "abc", Size: 3, MtimeNs: 1}, + }, + New: nil, + } + require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle.triggers.on_file_change"), change, nil)) + assert.Equal(t, deployplan.Update, change.Action) + assert.Equal(t, "trigger removed", change.Reason) + }) + t.Run("fresh fingerprint still recreates", func(t *testing.T) { change := &ChangeDesc{ Action: deployplan.Recreate, @@ -410,6 +458,20 @@ func TestJobRunOverrideChangeDescTriggerRemoved(t *testing.T) { require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle.triggers.on_bundle_deploy"), change, nil)) assert.Equal(t, deployplan.Recreate, change.Action) }) + + t.Run("changed on_file_change fingerprint still recreates", func(t *testing.T) { + change := &ChangeDesc{ + Action: deployplan.Recreate, + Old: map[string]resources.JobRunFileFingerprint{ + "a.txt": {Hash: "old", Size: 1, MtimeNs: 1}, + }, + New: map[string]resources.JobRunFileFingerprint{ + "a.txt": {Hash: "new", Size: 1, MtimeNs: 2}, + }, + } + require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle.triggers.on_file_change"), change, nil)) + assert.Equal(t, deployplan.Recreate, change.Action) + }) } // The planner diffs RemapState(remote) against PrepareState(config), so a run @@ -449,6 +511,20 @@ func TestJobRunIgnoresEveryRequestField(t *testing.T) { assert.False(t, ignoresRemoteChanges(ignored, "result_state"), "result_state must stay comparable against the remote") } +func TestJobRunIgnoresMtimeOnlyFileTriggerDrift(t *testing.T) { + adapters, err := InitAll(nil) + require.NoError(t, err) + ignored := adapters["job_runs"].ResourceConfig().IgnoreLocalChanges + path := structpath.MustParsePath("lifecycle.triggers.on_file_change['seed.txt'].mtime_ns") + assert.True(t, slices.ContainsFunc(ignored, func(r FieldRule) bool { + return path.HasPatternPrefix(r.Field) + }), "mtime-only fingerprint drift must be ignored") + hashPath := structpath.MustParsePath("lifecycle.triggers.on_file_change['seed.txt'].hash") + assert.False(t, slices.ContainsFunc(ignored, func(r FieldRule) bool { + return hashPath.HasPatternPrefix(r.Field) + }), "hash changes must still recreate") +} + // ignoresRemoteChanges reports whether the rules suppress remote drift on field. func ignoresRemoteChanges(rules []FieldRule, field string) bool { path := structpath.MustParsePath(field) diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index e98a1ac2330..5559f7fa5bf 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -182,6 +182,12 @@ resources: recreate_on_changes: - reason: immutable + # mtime is stored for the resolve fast path; content identity is the hash. + # Ignoring mtime-only local drift keeps touch from recreating the run. + ignore_local_changes: + - field: lifecycle.triggers.on_file_change[*].mtime_ns + reason: mtime_only_fingerprint + pipelines: recreate_on_changes: - field: storage diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index a47e8151681..bfecaef7c00 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -990,6 +990,9 @@ resources: "on_bundle_deploy": "description": |- If true, re-fire the run on every bundle deploy. Incompatible with lifecycle.prevent_destroy. + "on_file_change": + "description": |- + Path or glob under the sync root. Re-fire the run when any matched file's content changes. Incompatible with lifecycle.prevent_destroy. "python_named_params": "description": |- PLACEHOLDER diff --git a/bundle/phases/initialize.go b/bundle/phases/initialize.go index b15e1c30df6..9eba6f466e0 100644 --- a/bundle/phases/initialize.go +++ b/bundle/phases/initialize.go @@ -26,7 +26,8 @@ import ( func Initialize(ctx context.Context, b *bundle.Bundle) { log.Info(ctx, "Phase: initialize") - bundle.ApplySeqContext(ctx, b, + bundle.ApplySeqContext( + ctx, b, // Reads (dynamic): resource.*.* // Checks that none of resources.. is nil. Raises error otherwise. validate.AllResourcesHaveValues(), @@ -191,6 +192,9 @@ func Initialize(ctx context.Context, b *bundle.Bundle) { // Reject invalid job_runs.lifecycle.triggers (empty, false, prevent_destroy). mutator.ValidateJobRunTriggers(), + // Expand on_file_change globs and hash matched files into ResolvedFileTriggers. + mutator.ResolveJobRunFileTriggers(), + // 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 3db0864c2c4..92ca524cc4f 100644 --- a/bundle/phases/plan.go +++ b/bundle/phases/plan.go @@ -21,7 +21,8 @@ import ( // PreDeployChecks is common set of mutators between "bundle plan" and "bundle deploy". // Note, it is not run in "bundle migrate" so it must not modify the config func PreDeployChecks(ctx context.Context, b *bundle.Bundle, isPlan bool, engine engine.EngineType) { - bundle.ApplySeqContext(ctx, b, + bundle.ApplySeqContext( + ctx, b, terraform.CheckDashboardsModifiedRemotely(isPlan, engine), resourcemutator.SecretScopeFixups(engine), deploy.StatePull(), @@ -30,6 +31,7 @@ func PreDeployChecks(ctx context.Context, b *bundle.Bundle, isPlan bool, engine mutator.ValidateLifecycleStarted(engine), mutator.ValidateCascadeOnDestroy(engine), mutator.ValidateJobRunTriggers(), + mutator.ResolveJobRunFileTriggers(), statemgmt.CheckRunningResource(engine), ) } diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index e0c710428a3..a520561d933 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1300,6 +1300,10 @@ "on_bundle_deploy": { "description": "If true, re-fire the run on every bundle deploy. Incompatible with lifecycle.prevent_destroy.", "$ref": "#/$defs/bool" + }, + "on_file_change": { + "description": "Path or glob under the sync root. Re-fire the run when any matched file's content changes. Incompatible with lifecycle.prevent_destroy.", + "$ref": "#/$defs/string" } }, "additionalProperties": false From 926b8cf555626bfaf34bb2c10799997d59b59100 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 18 Aug 2026 12:41:58 +0000 Subject: [PATCH 02/16] job_runs: fix exhaustruct and regenerate refschema for on_file_change Satisfy the linter on PrepareState literals after adding OnFileChange, and refresh out.fields.txt so validate-generated stays green. --- acceptance/bundle/refschema/out.fields.txt | 11 +++++++++++ bundle/direct/dresources/job_run.go | 11 +++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index 49418540acf..8d956f2740c 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -852,8 +852,14 @@ 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.on_file_change map[string]resources.JobRunFileFingerprint STATE +resources.job_runs.*.lifecycle.triggers.on_file_change.* resources.JobRunFileFingerprint STATE +resources.job_runs.*.lifecycle.triggers.on_file_change.*.hash string STATE +resources.job_runs.*.lifecycle.triggers.on_file_change.*.mtime_ns int64 STATE +resources.job_runs.*.lifecycle.triggers.on_file_change.*.size int64 STATE resources.job_runs.*.lifecycle.triggers[*] resources.JobRunTrigger INPUT resources.job_runs.*.lifecycle.triggers[*].on_bundle_deploy *bool INPUT +resources.job_runs.*.lifecycle.triggers[*].on_file_change *string INPUT resources.job_runs.*.modified_status string INPUT resources.job_runs.*.notebook_params map[string]string ALL resources.job_runs.*.notebook_params.* string ALL @@ -876,6 +882,11 @@ resources.job_runs.*.python_params []string ALL resources.job_runs.*.python_params[*] string ALL resources.job_runs.*.queue *jobs.QueueSettings ALL resources.job_runs.*.queue.enabled bool ALL +resources.job_runs.*.resolved_file_triggers map[string]resources.JobRunFileFingerprint INPUT +resources.job_runs.*.resolved_file_triggers.* resources.JobRunFileFingerprint INPUT +resources.job_runs.*.resolved_file_triggers.*.hash string INPUT +resources.job_runs.*.resolved_file_triggers.*.mtime_ns int64 INPUT +resources.job_runs.*.resolved_file_triggers.*.size int64 INPUT resources.job_runs.*.resolved_job_id int64 INPUT resources.job_runs.*.result_state jobs.RunResultState REMOTE STATE resources.job_runs.*.run_id int64 REMOTE diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 219cf2ab740..db202c70954 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -108,14 +108,21 @@ func (*ResourceJobRun) PrepareState(input *resources.JobRun) *JobRunState { state := &JobRunState{ RunNow: input.RunNow, ResultState: jobs.RunResultStateSuccess, + Lifecycle: nil, } var triggers *JobRunTriggersState if input.HasOnBundleDeploy() { - triggers = &JobRunTriggersState{OnBundleDeploy: uuid.NewString()} + triggers = &JobRunTriggersState{ + OnBundleDeploy: uuid.NewString(), + OnFileChange: nil, + } } if len(input.ResolvedFileTriggers) > 0 { if triggers == nil { - triggers = &JobRunTriggersState{} + triggers = &JobRunTriggersState{ + OnBundleDeploy: "", + OnFileChange: nil, + } } triggers.OnFileChange = input.ResolvedFileTriggers } From f181266ab18ae3238634ec5d4c59b549920d586b Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 18 Aug 2026 13:03:41 +0000 Subject: [PATCH 03/16] job_runs: tighten on_file_change path and directory checks Reject patterns outside the sync root and directory-only matches so the trigger cannot fingerprint escaped paths or silently disarm. --- .../bundles/job-runs-on-file-change.md | 2 +- .../mutator/resolve_job_run_file_triggers.go | 31 ++++++++++++++++--- .../resolve_job_run_file_triggers_test.go | 25 +++++++++++++++ bundle/internal/schema/annotations.yml | 2 +- bundle/schema/jsonschema.json | 2 +- 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/.nextchanges/bundles/job-runs-on-file-change.md b/.nextchanges/bundles/job-runs-on-file-change.md index 00682403a0d..eadceda6268 100644 --- a/.nextchanges/bundles/job-runs-on-file-change.md +++ b/.nextchanges/bundles/job-runs-on-file-change.md @@ -1 +1 @@ -direct: `resources.job_runs` can set `lifecycle.triggers.on_file_change` to a path or glob to re-fire the run when matched file contents change. Removing the trigger does not recreate the existing run. +direct: `resources.job_runs` can set `lifecycle.triggers.on_file_change` to a path or glob under the sync root to re-fire the run when matched file contents change, or when matches appear or disappear. Removing the trigger does not recreate the existing run. diff --git a/bundle/config/mutator/resolve_job_run_file_triggers.go b/bundle/config/mutator/resolve_job_run_file_triggers.go index a82ed654be3..f0338b2d50a 100644 --- a/bundle/config/mutator/resolve_job_run_file_triggers.go +++ b/bundle/config/mutator/resolve_job_run_file_triggers.go @@ -80,7 +80,17 @@ func resolveFileTriggers(b *bundle.Bundle, name string, patterns []string, prev out := make(map[string]resources.JobRunFileFingerprint) for _, pattern := range patterns { path := fmt.Sprintf("resources.job_runs.%s.lifecycle.triggers", name) - matches, err := filepath.Glob(filepath.Join(b.SyncRootPath, filepath.FromSlash(pattern))) + localPattern := filepath.FromSlash(pattern) + // Keep fingerprints under SyncRoot; same IsLocal gate as translate_paths. + if !filepath.IsLocal(localPattern) { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: pattern %q is not under the sync root", pattern), + Locations: b.Config.GetLocations(path), + }) + continue + } + matches, err := filepath.Glob(filepath.Join(b.SyncRootPath, localPattern)) if err != nil { diags = diags.Append(diag.Diagnostic{ Severity: diag.Error, @@ -90,7 +100,7 @@ func resolveFileTriggers(b *bundle.Bundle, name string, patterns []string, prev continue } if len(matches) == 0 { - // Distinct state when the path/glob matches nothing (design doc). + // Distinct state when the path/glob matches nothing so appear/disappear recreates. out[filepath.ToSlash(pattern)] = resources.JobRunFileFingerprint{ Size: missingFileSentinelSize, } @@ -101,6 +111,8 @@ func resolveFileTriggers(b *bundle.Bundle, name string, patterns []string, prev }) continue } + regularMatches := 0 + sawNonRegular := false for _, match := range matches { info, err := os.Stat(match) if err != nil { @@ -112,13 +124,15 @@ func resolveFileTriggers(b *bundle.Bundle, name string, patterns []string, prev continue } if !info.Mode().IsRegular() { + sawNonRegular = true continue } + regularMatches++ rel, err := filepath.Rel(b.SyncRootPath, match) - if err != nil { + if err != nil || !filepath.IsLocal(rel) { diags = diags.Append(diag.Diagnostic{ Severity: diag.Error, - Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: relative path for %q: %s", match, err), + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: matched path %q is not under the sync root", match), Locations: b.Config.GetLocations(path), }) continue @@ -135,6 +149,15 @@ func resolveFileTriggers(b *bundle.Bundle, name string, patterns []string, prev } out[key] = fp } + // A directory-only match would otherwise leave ResolvedFileTriggers empty + // and silently disarm the trigger while config still sets on_file_change. + if regularMatches == 0 && sawNonRegular { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: pattern %q matches no regular files", pattern), + Locations: b.Config.GetLocations(path), + }) + } } return out, diags } diff --git a/bundle/config/mutator/resolve_job_run_file_triggers_test.go b/bundle/config/mutator/resolve_job_run_file_triggers_test.go index 9a9cf77bf53..06266d75894 100644 --- a/bundle/config/mutator/resolve_job_run_file_triggers_test.go +++ b/bundle/config/mutator/resolve_job_run_file_triggers_test.go @@ -113,6 +113,31 @@ func TestResolveJobRunFileTriggers(t *testing.T) { assertFingerprint(t, fps["a.txt"], "aaa") assertFingerprint(t, fps["subdir/x.py"], "bbb") }) + + t.Run("pattern outside sync root is an error", func(t *testing.T) { + dir := t.TempDir() + pattern := "../outside.txt" + b := bundleWithFileTrigger(dir, pattern) + + diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) + require.True(t, diags.HasError()) + require.Len(t, diags, 1) + assert.Contains(t, diags[0].Summary, `not under the sync root`) + assert.Empty(t, b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers) + }) + + t.Run("directory-only match is an error", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "migrations"), 0o755)) + pattern := "migrations" + b := bundleWithFileTrigger(dir, pattern) + + diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) + require.True(t, diags.HasError()) + require.Len(t, diags, 1) + assert.Contains(t, diags[0].Summary, `matches no regular files`) + assert.Empty(t, b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers) + }) } func bundleWithFileTrigger(syncRoot, pattern string) *bundle.Bundle { diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index bfecaef7c00..8f8dc45d066 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -992,7 +992,7 @@ resources: If true, re-fire the run on every bundle deploy. Incompatible with lifecycle.prevent_destroy. "on_file_change": "description": |- - Path or glob under the sync root. Re-fire the run when any matched file's content changes. Incompatible with lifecycle.prevent_destroy. + Path or glob under the sync root (filepath.Glob; `**` is not supported). Re-fire the run when a matched file's content changes, or when the set of matches appears or disappears. Incompatible with lifecycle.prevent_destroy. "python_named_params": "description": |- PLACEHOLDER diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index a520561d933..69f9dcca0db 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1302,7 +1302,7 @@ "$ref": "#/$defs/bool" }, "on_file_change": { - "description": "Path or glob under the sync root. Re-fire the run when any matched file's content changes. Incompatible with lifecycle.prevent_destroy.", + "description": "Path or glob under the sync root (filepath.Glob; `**` is not supported). Re-fire the run when a matched file's content changes, or when the set of matches appears or disappears. Incompatible with lifecycle.prevent_destroy.", "$ref": "#/$defs/string" } }, From 7255a17c25ea0627645c0801a5d5865f6e0f71a4 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 18 Aug 2026 13:16:41 +0000 Subject: [PATCH 04/16] job_runs: store on_file_change as path-to-hash only Drop size/mtime fingerprints and the resolve fast path so content identity alone drives recreate, and touch no longer needs a planner exception. --- .../bundles/job-runs-on-file-change.md | 2 +- acceptance/bundle/refschema/out.fields.txt | 14 +- .../job_runs/on_file_change/output.txt | 11 + .../resources/job_runs/on_file_change/script | 6 + .../mutator/resolve_job_run_file_triggers.go | 204 +++++++----------- ...lve_job_run_file_triggers_internal_test.go | 73 ------- .../resolve_job_run_file_triggers_test.go | 48 +++-- bundle/config/resources/job_run.go | 19 +- bundle/config/resources/lifecycle.go | 6 - bundle/direct/dresources/job_run.go | 4 +- bundle/direct/dresources/job_run_test.go | 42 +--- bundle/direct/dresources/resources.yml | 6 - bundle/internal/schema/annotations.yml | 2 +- bundle/schema/jsonschema.json | 2 +- 14 files changed, 141 insertions(+), 298 deletions(-) delete mode 100644 bundle/config/mutator/resolve_job_run_file_triggers_internal_test.go diff --git a/.nextchanges/bundles/job-runs-on-file-change.md b/.nextchanges/bundles/job-runs-on-file-change.md index eadceda6268..95869a25ccf 100644 --- a/.nextchanges/bundles/job-runs-on-file-change.md +++ b/.nextchanges/bundles/job-runs-on-file-change.md @@ -1 +1 @@ -direct: `resources.job_runs` can set `lifecycle.triggers.on_file_change` to a path or glob under the sync root to re-fire the run when matched file contents change, or when matches appear or disappear. Removing the trigger does not recreate the existing run. +direct: `resources.job_runs` can set `lifecycle.triggers.on_file_change` to a path or glob under the sync root to re-fire the run when matched file contents change (content hash), or when matches appear or disappear. 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 8d956f2740c..b84842ffdc6 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -852,11 +852,8 @@ 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.on_file_change map[string]resources.JobRunFileFingerprint STATE -resources.job_runs.*.lifecycle.triggers.on_file_change.* resources.JobRunFileFingerprint STATE -resources.job_runs.*.lifecycle.triggers.on_file_change.*.hash string STATE -resources.job_runs.*.lifecycle.triggers.on_file_change.*.mtime_ns int64 STATE -resources.job_runs.*.lifecycle.triggers.on_file_change.*.size int64 STATE +resources.job_runs.*.lifecycle.triggers.on_file_change map[string]string STATE +resources.job_runs.*.lifecycle.triggers.on_file_change.* string STATE resources.job_runs.*.lifecycle.triggers[*] resources.JobRunTrigger INPUT resources.job_runs.*.lifecycle.triggers[*].on_bundle_deploy *bool INPUT resources.job_runs.*.lifecycle.triggers[*].on_file_change *string INPUT @@ -882,11 +879,8 @@ resources.job_runs.*.python_params []string ALL resources.job_runs.*.python_params[*] string ALL resources.job_runs.*.queue *jobs.QueueSettings ALL resources.job_runs.*.queue.enabled bool ALL -resources.job_runs.*.resolved_file_triggers map[string]resources.JobRunFileFingerprint INPUT -resources.job_runs.*.resolved_file_triggers.* resources.JobRunFileFingerprint INPUT -resources.job_runs.*.resolved_file_triggers.*.hash string INPUT -resources.job_runs.*.resolved_file_triggers.*.mtime_ns int64 INPUT -resources.job_runs.*.resolved_file_triggers.*.size int64 INPUT +resources.job_runs.*.resolved_file_triggers map[string]string INPUT +resources.job_runs.*.resolved_file_triggers.* string INPUT resources.job_runs.*.resolved_job_id int64 INPUT resources.job_runs.*.result_state jobs.RunResultState REMOTE STATE resources.job_runs.*.run_id int64 REMOTE diff --git a/acceptance/bundle/resources/job_runs/on_file_change/output.txt b/acceptance/bundle/resources/job_runs/on_file_change/output.txt index bcf5962f8fa..f356fe7fb61 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change/output.txt +++ b/acceptance/bundle/resources/job_runs/on_file_change/output.txt @@ -33,6 +33,17 @@ Resources: 0 created, 0 changed, 0 deleted, 2 unchanged >>> print_requests.py //jobs/run-now +=== touch without content change plans nothing +>>> [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-file-change/default/files... +Files: 3 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 2 unchanged + +>>> print_requests.py //jobs/run-now + === editing the file re-fires >>> [CLI] bundle plan recreate job_runs.my_run diff --git a/acceptance/bundle/resources/job_runs/on_file_change/script b/acceptance/bundle/resources/job_runs/on_file_change/script index ff162e11f0b..0506dd5e106 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change/script +++ b/acceptance/bundle/resources/job_runs/on_file_change/script @@ -16,6 +16,12 @@ trace $CLI bundle plan trace $CLI bundle deploy trace print_requests.py //jobs/run-now +title "touch without content change plans nothing" +python -c "import os; os.utime('seed.txt', None)" +trace $CLI bundle plan +trace $CLI bundle deploy +trace print_requests.py //jobs/run-now + title "editing the file re-fires" update_file.py seed.txt "v1" "v2" trace $CLI bundle plan diff --git a/bundle/config/mutator/resolve_job_run_file_triggers.go b/bundle/config/mutator/resolve_job_run_file_triggers.go index f0338b2d50a..55a55a94b95 100644 --- a/bundle/config/mutator/resolve_job_run_file_triggers.go +++ b/bundle/config/mutator/resolve_job_run_file_triggers.go @@ -4,25 +4,23 @@ import ( "context" "crypto/sha256" "encoding/hex" - "encoding/json" "fmt" "io" "os" "path/filepath" + "strings" "github.com/databricks/cli/bundle" - "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/libs/diag" ) -// missingFileSentinelSize marks a pattern with no matching file so the next -// plan can distinguish "still missing" from "file appeared". -const missingFileSentinelSize = int64(-1) +// missingFileHash marks a pattern with no matching file so appear/disappear recreates. +const missingFileHash = "" type resolveJobRunFileTriggers struct{} // ResolveJobRunFileTriggers expands on_file_change globs and stores per-file -// fingerprints on each job_run for PrepareState to copy into local state. +// content hashes on each job_run for PrepareState to copy into local state. func ResolveJobRunFileTriggers() bundle.Mutator { return &resolveJobRunFileTriggers{} } @@ -34,155 +32,105 @@ func (*resolveJobRunFileTriggers) Name() string { func (*resolveJobRunFileTriggers) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnostics { var diags diag.Diagnostics for name, jr := range b.Config.Resources.JobRuns { - if jr == nil { + if jr == nil || jr.Lifecycle == nil { continue } - patterns := jr.OnFileChangePatterns() - if len(patterns) == 0 { - continue + out := make(map[string]string) + for i, t := range jr.Lifecycle.Triggers { + if t.OnFileChange == nil { + continue + } + path := fmt.Sprintf("resources.job_runs.%s.lifecycle.triggers[%d].on_file_change", name, i) + hashes, d := resolveFileTrigger(b, path, strings.TrimSpace(*t.OnFileChange)) + diags = diags.Extend(d) + for k, v := range hashes { + out[k] = v + } + } + if len(out) == 0 { + jr.ResolvedFileTriggers = nil + } else { + jr.ResolvedFileTriggers = out } - fps, d := resolveFileTriggers(b, name, patterns, previousFileTriggers(b, name)) - diags = diags.Extend(d) - jr.ResolvedFileTriggers = fps } return diags } -// previousFileTriggers reads on_file_change fingerprints from deployment state -// when it is open (plan/deploy after StatePull). Used so unchanged content keeps -// a stable fingerprint across mtime-only updates (e.g. touch). -func previousFileTriggers(b *bundle.Bundle, name string) map[string]resources.JobRunFileFingerprint { - if b.DeploymentBundle.StateDB.Path == "" { - return nil - } - entry, ok := b.DeploymentBundle.StateDB.GetResourceEntry("resources.job_runs." + name) - if !ok || len(entry.State) == 0 { - return nil - } - var state struct { - Lifecycle *struct { - Triggers *struct { - OnFileChange map[string]resources.JobRunFileFingerprint `json:"on_file_change"` - } `json:"triggers"` - } `json:"lifecycle"` +func resolveFileTrigger(b *bundle.Bundle, loc, pattern string) (map[string]string, diag.Diagnostics) { + var diags diag.Diagnostics + out := make(map[string]string) + localPattern := filepath.FromSlash(pattern) + // Keep hashes under SyncRoot; same IsLocal gate as translate_paths. + if !filepath.IsLocal(localPattern) { + return out, diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: pattern %q is not under the sync root", pattern), + Locations: b.Config.GetLocations(loc), + }) } - if err := json.Unmarshal(entry.State, &state); err != nil { - return nil + matches, err := filepath.Glob(filepath.Join(b.SyncRootPath, localPattern)) + if err != nil { + return out, diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: invalid pattern %q: %s", pattern, err), + Locations: b.Config.GetLocations(loc), + }) } - if state.Lifecycle == nil || state.Lifecycle.Triggers == nil { - return nil + if len(matches) == 0 { + out[filepath.ToSlash(pattern)] = missingFileHash + return out, diags.Append(diag.Diagnostic{ + Severity: diag.Warning, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: no files match %q", pattern), + Locations: b.Config.GetLocations(loc), + }) } - return state.Lifecycle.Triggers.OnFileChange -} - -func resolveFileTriggers(b *bundle.Bundle, name string, patterns []string, prev map[string]resources.JobRunFileFingerprint) (map[string]resources.JobRunFileFingerprint, diag.Diagnostics) { - var diags diag.Diagnostics - out := make(map[string]resources.JobRunFileFingerprint) - for _, pattern := range patterns { - path := fmt.Sprintf("resources.job_runs.%s.lifecycle.triggers", name) - localPattern := filepath.FromSlash(pattern) - // Keep fingerprints under SyncRoot; same IsLocal gate as translate_paths. - if !filepath.IsLocal(localPattern) { + regularMatches := 0 + sawNonRegular := false + for _, match := range matches { + info, err := os.Stat(match) + if err != nil { diags = diags.Append(diag.Diagnostic{ Severity: diag.Error, - Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: pattern %q is not under the sync root", pattern), - Locations: b.Config.GetLocations(path), + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: stat %q: %s", match, err), + Locations: b.Config.GetLocations(loc), }) continue } - matches, err := filepath.Glob(filepath.Join(b.SyncRootPath, localPattern)) - if err != nil { - diags = diags.Append(diag.Diagnostic{ - Severity: diag.Error, - Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: invalid pattern %q: %s", pattern, err), - Locations: b.Config.GetLocations(path), - }) + if !info.Mode().IsRegular() { + sawNonRegular = true continue } - if len(matches) == 0 { - // Distinct state when the path/glob matches nothing so appear/disappear recreates. - out[filepath.ToSlash(pattern)] = resources.JobRunFileFingerprint{ - Size: missingFileSentinelSize, - } + regularMatches++ + rel, err := filepath.Rel(b.SyncRootPath, match) + if err != nil || !filepath.IsLocal(rel) { diags = diags.Append(diag.Diagnostic{ - Severity: diag.Warning, - Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: no files match %q", pattern), - Locations: b.Config.GetLocations(path), + Severity: diag.Error, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: matched path %q is not under the sync root", match), + Locations: b.Config.GetLocations(loc), }) continue } - regularMatches := 0 - sawNonRegular := false - for _, match := range matches { - info, err := os.Stat(match) - if err != nil { - diags = diags.Append(diag.Diagnostic{ - Severity: diag.Error, - Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: stat %q: %s", match, err), - Locations: b.Config.GetLocations(path), - }) - continue - } - if !info.Mode().IsRegular() { - sawNonRegular = true - continue - } - regularMatches++ - rel, err := filepath.Rel(b.SyncRootPath, match) - if err != nil || !filepath.IsLocal(rel) { - diags = diags.Append(diag.Diagnostic{ - Severity: diag.Error, - Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: matched path %q is not under the sync root", match), - Locations: b.Config.GetLocations(path), - }) - continue - } - key := filepath.ToSlash(rel) - fp, err := fingerprintFile(match, info, prev[key]) - if err != nil { - diags = diags.Append(diag.Diagnostic{ - Severity: diag.Error, - Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: hash %q: %s", match, err), - Locations: b.Config.GetLocations(path), - }) - continue - } - out[key] = fp - } - // A directory-only match would otherwise leave ResolvedFileTriggers empty - // and silently disarm the trigger while config still sets on_file_change. - if regularMatches == 0 && sawNonRegular { + hash, err := hashFile(match) + if err != nil { diags = diags.Append(diag.Diagnostic{ Severity: diag.Error, - Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: pattern %q matches no regular files", pattern), - Locations: b.Config.GetLocations(path), + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: hash %q: %s", match, err), + Locations: b.Config.GetLocations(loc), }) + continue } + out[filepath.ToSlash(rel)] = hash } - return out, diags -} - -// fingerprintFile builds a content fingerprint. If prev has the same size and -// mtime, it is reused without reading the file. If content hash matches prev, -// prev is reused so mtime-only updates (touch) do not change planned state. -func fingerprintFile(path string, info os.FileInfo, prev resources.JobRunFileFingerprint) (resources.JobRunFileFingerprint, error) { - size := info.Size() - mtime := info.ModTime().UnixNano() - if prev.Hash != "" && prev.Size == size && prev.MtimeNs == mtime { - return prev, nil + // A directory-only match would otherwise leave ResolvedFileTriggers empty + // and silently disarm the trigger while config still sets on_file_change. + if regularMatches == 0 && sawNonRegular { + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: pattern %q matches no regular files", pattern), + Locations: b.Config.GetLocations(loc), + }) } - hash, err := hashFile(path) - if err != nil { - return resources.JobRunFileFingerprint{}, err - } - if prev.Hash != "" && prev.Hash == hash { - return prev, nil - } - return resources.JobRunFileFingerprint{ - Hash: hash, - Size: size, - MtimeNs: mtime, - }, nil + return out, diags } func hashFile(path string) (string, error) { diff --git a/bundle/config/mutator/resolve_job_run_file_triggers_internal_test.go b/bundle/config/mutator/resolve_job_run_file_triggers_internal_test.go deleted file mode 100644 index e82bd22945b..00000000000 --- a/bundle/config/mutator/resolve_job_run_file_triggers_internal_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package mutator - -import ( - "os" - "path/filepath" - "testing" - "time" - - "github.com/databricks/cli/bundle/config/resources" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFingerprintFileContentStableAcrossTouch(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "seed.txt") - require.NoError(t, os.WriteFile(path, []byte("v1"), 0o644)) - - info, err := os.Stat(path) - require.NoError(t, err) - first, err := fingerprintFile(path, info, resources.JobRunFileFingerprint{}) - require.NoError(t, err) - require.NotEmpty(t, first.Hash) - - // Advance mtime without changing contents (touch). - require.NoError(t, os.Chtimes(path, time.Now().Add(time.Minute), time.Now().Add(time.Minute))) - info, err = os.Stat(path) - require.NoError(t, err) - assert.NotEqual(t, first.MtimeNs, info.ModTime().UnixNano()) - - second, err := fingerprintFile(path, info, first) - require.NoError(t, err) - assert.Equal(t, first, second, "unchanged content must reuse the previous fingerprint") -} - -func TestFingerprintFileFastPathSkipsWhenMtimeAndSizeMatch(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "seed.txt") - require.NoError(t, os.WriteFile(path, []byte("v1"), 0o644)) - - info, err := os.Stat(path) - require.NoError(t, err) - prev := resources.JobRunFileFingerprint{ - Hash: "not-the-real-hash", - Size: info.Size(), - MtimeNs: info.ModTime().UnixNano(), - } - - got, err := fingerprintFile(path, info, prev) - require.NoError(t, err) - assert.Equal(t, prev, got, "matching size+mtime must reuse prev without re-hashing") -} - -func TestFingerprintFileContentChange(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "seed.txt") - require.NoError(t, os.WriteFile(path, []byte("v1"), 0o644)) - - info, err := os.Stat(path) - require.NoError(t, err) - first, err := fingerprintFile(path, info, resources.JobRunFileFingerprint{}) - require.NoError(t, err) - - // Different size so the mtime+size fast path cannot reuse prev. - require.NoError(t, os.WriteFile(path, []byte("v2-changed"), 0o644)) - info, err = os.Stat(path) - require.NoError(t, err) - second, err := fingerprintFile(path, info, first) - require.NoError(t, err) - assert.NotEqual(t, first.Hash, second.Hash) - assert.Equal(t, info.Size(), second.Size) - assert.Equal(t, info.ModTime().UnixNano(), second.MtimeNs) -} diff --git a/bundle/config/mutator/resolve_job_run_file_triggers_test.go b/bundle/config/mutator/resolve_job_run_file_triggers_test.go index 06266d75894..3ec663c89bd 100644 --- a/bundle/config/mutator/resolve_job_run_file_triggers_test.go +++ b/bundle/config/mutator/resolve_job_run_file_triggers_test.go @@ -17,7 +17,7 @@ import ( ) func TestResolveJobRunFileTriggers(t *testing.T) { - t.Run("matches files and fills fingerprints", func(t *testing.T) { + t.Run("matches files and fills hashes", func(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), []byte("world"), 0o644)) @@ -28,14 +28,13 @@ func TestResolveJobRunFileTriggers(t *testing.T) { diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) require.False(t, diags.HasError()) - fps := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers - require.Len(t, fps, 2) - - assertFingerprint(t, fps["a.txt"], "hello") - assertFingerprint(t, fps["b.txt"], "world") + hashes := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers + require.Len(t, hashes, 2) + assert.Equal(t, contentHash("hello"), hashes["a.txt"]) + assert.Equal(t, contentHash("world"), hashes["b.txt"]) }) - t.Run("no matches warns and stores sentinel", func(t *testing.T) { + t.Run("no matches warns and stores empty hash", func(t *testing.T) { dir := t.TempDir() pattern := "missing.txt" b := bundleWithFileTrigger(dir, pattern) @@ -46,12 +45,9 @@ func TestResolveJobRunFileTriggers(t *testing.T) { assert.Equal(t, diag.Warning, diags[0].Severity) assert.Contains(t, diags[0].Summary, `no files match "missing.txt"`) - fps := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers - require.Len(t, fps, 1) - fp := fps["missing.txt"] - assert.Empty(t, fp.Hash) - assert.Equal(t, int64(-1), fp.Size) - assert.Zero(t, fp.MtimeNs) + hashes := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers + require.Len(t, hashes, 1) + assert.Empty(t, hashes["missing.txt"]) }) t.Run("no file triggers is a no-op", func(t *testing.T) { @@ -108,10 +104,10 @@ func TestResolveJobRunFileTriggers(t *testing.T) { diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) require.False(t, diags.HasError()) - fps := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers - require.Len(t, fps, 2) - assertFingerprint(t, fps["a.txt"], "aaa") - assertFingerprint(t, fps["subdir/x.py"], "bbb") + hashes := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers + require.Len(t, hashes, 2) + assert.Equal(t, contentHash("aaa"), hashes["a.txt"]) + assert.Equal(t, contentHash("bbb"), hashes["subdir/x.py"]) }) t.Run("pattern outside sync root is an error", func(t *testing.T) { @@ -138,6 +134,17 @@ func TestResolveJobRunFileTriggers(t *testing.T) { assert.Contains(t, diags[0].Summary, `matches no regular files`) assert.Empty(t, b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers) }) + + t.Run("trims pattern whitespace", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "seed.txt"), []byte("v1"), 0o644)) + pattern := " seed.txt " + b := bundleWithFileTrigger(dir, pattern) + + diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) + require.False(t, diags.HasError()) + assert.Equal(t, contentHash("v1"), b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers["seed.txt"]) + }) } func bundleWithFileTrigger(syncRoot, pattern string) *bundle.Bundle { @@ -159,10 +166,7 @@ func bundleWithFileTrigger(syncRoot, pattern string) *bundle.Bundle { } } -func assertFingerprint(t *testing.T, fp resources.JobRunFileFingerprint, content string) { - t.Helper() +func contentHash(content string) string { sum := sha256.Sum256([]byte(content)) - assert.Equal(t, hex.EncodeToString(sum[:]), fp.Hash) - assert.Equal(t, int64(len(content)), fp.Size) - assert.NotZero(t, fp.MtimeNs) + return hex.EncodeToString(sum[:]) } diff --git a/bundle/config/resources/job_run.go b/bundle/config/resources/job_run.go index cf66c71f728..f4b16963453 100644 --- a/bundle/config/resources/job_run.go +++ b/bundle/config/resources/job_run.go @@ -28,9 +28,9 @@ type JobRun struct { // reference) lets state loading preserve that reference and its plan dependency. ResolvedJobID int64 `json:"resolved_job_id,omitempty" bundle:"internal"` - // ResolvedFileTriggers is the per-file fingerprint map for on_file_change, - // computed under SyncRoot before plan. bundle:"internal" keeps it out of schema. - ResolvedFileTriggers map[string]JobRunFileFingerprint `json:"resolved_file_triggers,omitempty" bundle:"internal"` + // ResolvedFileTriggers is path → content hash for on_file_change, computed + // under SyncRoot before plan. bundle:"internal" keeps it out of schema. + ResolvedFileTriggers map[string]string `json:"resolved_file_triggers,omitempty" bundle:"internal"` } // HasOnBundleDeploy reports whether any trigger re-fires on every deploy. @@ -58,19 +58,6 @@ func (r *JobRun) HasOnFileChange() bool { return false } -func (r *JobRun) OnFileChangePatterns() []string { - if r.Lifecycle == nil { - return nil - } - var patterns []string - for _, t := range r.Lifecycle.Triggers { - if t.OnFileChange != nil { - patterns = append(patterns, *t.OnFileChange) - } - } - return patterns -} - 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 59ccb682b81..a4e881d298c 100644 --- a/bundle/config/resources/lifecycle.go +++ b/bundle/config/resources/lifecycle.go @@ -39,9 +39,3 @@ type JobRunTrigger struct { OnBundleDeploy *bool `json:"on_bundle_deploy,omitempty"` OnFileChange *string `json:"on_file_change,omitempty"` // path or glob under sync root } - -type JobRunFileFingerprint struct { - Hash string `json:"hash"` - Size int64 `json:"size"` - MtimeNs int64 `json:"mtime_ns"` -} diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index db202c70954..c7bc11d62a0 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -39,8 +39,8 @@ var jobRunTriggerLocalPaths = []string{ type JobRunTriggersState struct { // Fresh UUID each plan while armed so Old!=New forces recreate. OnBundleDeploy string `json:"on_bundle_deploy,omitempty"` - // Per-file fingerprints from ResolveJobRunFileTriggers; change to recreate. - OnFileChange map[string]resources.JobRunFileFingerprint `json:"on_file_change,omitempty"` + // Path → content hash from ResolveJobRunFileTriggers; change to recreate. + OnFileChange map[string]string `json:"on_file_change,omitempty"` } // JobRunLifecycleState holds local-only lifecycle fields persisted in state. diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index e374e7b0130..2c5815d3a20 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -377,9 +377,7 @@ func TestJobRunPrepareStateOnBundleDeploy(t *testing.T) { } func TestJobRunPrepareStateOnFileChange(t *testing.T) { - fps := map[string]resources.JobRunFileFingerprint{ - "a.txt": {Hash: "abc", Size: 3, MtimeNs: 1}, - } + hashes := map[string]string{"a.txt": "abc"} t.Run("unset", func(t *testing.T) { state := (&ResourceJobRun{}).PrepareState(&resources.JobRun{}) @@ -388,11 +386,11 @@ func TestJobRunPrepareStateOnFileChange(t *testing.T) { t.Run("armed", func(t *testing.T) { state := (&ResourceJobRun{}).PrepareState(&resources.JobRun{ - ResolvedFileTriggers: fps, + ResolvedFileTriggers: hashes, }) require.NotNil(t, state.Lifecycle) require.NotNil(t, state.Lifecycle.Triggers) - assert.Equal(t, fps, state.Lifecycle.Triggers.OnFileChange) + assert.Equal(t, hashes, state.Lifecycle.Triggers.OnFileChange) assert.Empty(t, state.Lifecycle.Triggers.OnBundleDeploy) }) @@ -402,12 +400,12 @@ func TestJobRunPrepareStateOnFileChange(t *testing.T) { Lifecycle: &resources.JobRunLifecycle{ Triggers: []resources.JobRunTrigger{{OnBundleDeploy: &on}}, }, - ResolvedFileTriggers: fps, + ResolvedFileTriggers: hashes, }) require.NotNil(t, state.Lifecycle) require.NotNil(t, state.Lifecycle.Triggers) assert.NotEmpty(t, state.Lifecycle.Triggers.OnBundleDeploy) - assert.Equal(t, fps, state.Lifecycle.Triggers.OnFileChange) + assert.Equal(t, hashes, state.Lifecycle.Triggers.OnFileChange) }) } @@ -439,10 +437,8 @@ func TestJobRunOverrideChangeDescTriggerRemoved(t *testing.T) { t.Run("clearing on_file_change leaf downgrades to update", func(t *testing.T) { change := &ChangeDesc{ Action: deployplan.Recreate, - Old: map[string]resources.JobRunFileFingerprint{ - "a.txt": {Hash: "abc", Size: 3, MtimeNs: 1}, - }, - New: nil, + Old: map[string]string{"a.txt": "abc"}, + New: nil, } require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle.triggers.on_file_change"), change, nil)) assert.Equal(t, deployplan.Update, change.Action) @@ -459,15 +455,11 @@ func TestJobRunOverrideChangeDescTriggerRemoved(t *testing.T) { assert.Equal(t, deployplan.Recreate, change.Action) }) - t.Run("changed on_file_change fingerprint still recreates", func(t *testing.T) { + t.Run("changed on_file_change hash still recreates", func(t *testing.T) { change := &ChangeDesc{ Action: deployplan.Recreate, - Old: map[string]resources.JobRunFileFingerprint{ - "a.txt": {Hash: "old", Size: 1, MtimeNs: 1}, - }, - New: map[string]resources.JobRunFileFingerprint{ - "a.txt": {Hash: "new", Size: 1, MtimeNs: 2}, - }, + Old: map[string]string{"a.txt": "old"}, + New: map[string]string{"a.txt": "new"}, } require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle.triggers.on_file_change"), change, nil)) assert.Equal(t, deployplan.Recreate, change.Action) @@ -511,20 +503,6 @@ func TestJobRunIgnoresEveryRequestField(t *testing.T) { assert.False(t, ignoresRemoteChanges(ignored, "result_state"), "result_state must stay comparable against the remote") } -func TestJobRunIgnoresMtimeOnlyFileTriggerDrift(t *testing.T) { - adapters, err := InitAll(nil) - require.NoError(t, err) - ignored := adapters["job_runs"].ResourceConfig().IgnoreLocalChanges - path := structpath.MustParsePath("lifecycle.triggers.on_file_change['seed.txt'].mtime_ns") - assert.True(t, slices.ContainsFunc(ignored, func(r FieldRule) bool { - return path.HasPatternPrefix(r.Field) - }), "mtime-only fingerprint drift must be ignored") - hashPath := structpath.MustParsePath("lifecycle.triggers.on_file_change['seed.txt'].hash") - assert.False(t, slices.ContainsFunc(ignored, func(r FieldRule) bool { - return hashPath.HasPatternPrefix(r.Field) - }), "hash changes must still recreate") -} - // ignoresRemoteChanges reports whether the rules suppress remote drift on field. func ignoresRemoteChanges(rules []FieldRule, field string) bool { path := structpath.MustParsePath(field) diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 5559f7fa5bf..e98a1ac2330 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -182,12 +182,6 @@ resources: recreate_on_changes: - reason: immutable - # mtime is stored for the resolve fast path; content identity is the hash. - # Ignoring mtime-only local drift keeps touch from recreating the run. - ignore_local_changes: - - field: lifecycle.triggers.on_file_change[*].mtime_ns - reason: mtime_only_fingerprint - pipelines: recreate_on_changes: - field: storage diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 8f8dc45d066..d48a3e2053a 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -992,7 +992,7 @@ resources: If true, re-fire the run on every bundle deploy. Incompatible with lifecycle.prevent_destroy. "on_file_change": "description": |- - Path or glob under the sync root (filepath.Glob; `**` is not supported). Re-fire the run when a matched file's content changes, or when the set of matches appears or disappears. Incompatible with lifecycle.prevent_destroy. + Path or glob under the sync root (filepath.Glob; `**` is not supported). Re-fire the run when a matched file's content hash changes, or when the set of matches appears or disappears. Incompatible with lifecycle.prevent_destroy. "python_named_params": "description": |- PLACEHOLDER diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 69f9dcca0db..9f56ca61a18 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1302,7 +1302,7 @@ "$ref": "#/$defs/bool" }, "on_file_change": { - "description": "Path or glob under the sync root (filepath.Glob; `**` is not supported). Re-fire the run when a matched file's content changes, or when the set of matches appears or disappears. Incompatible with lifecycle.prevent_destroy.", + "description": "Path or glob under the sync root (filepath.Glob; `**` is not supported). Re-fire the run when a matched file's content hash changes, or when the set of matches appears or disappears. Incompatible with lifecycle.prevent_destroy.", "$ref": "#/$defs/string" } }, From a02fd3d71dd8ff3cd1b7d3d1fcd6b44ac47e2942 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 18 Aug 2026 13:23:44 +0000 Subject: [PATCH 05/16] job_runs: use maps.Copy in on_file_change hash merge The modernize linter rejects the manual m[k]=v copy loop, failing CI lint. --- bundle/config/mutator/resolve_job_run_file_triggers.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bundle/config/mutator/resolve_job_run_file_triggers.go b/bundle/config/mutator/resolve_job_run_file_triggers.go index 55a55a94b95..ead17c59e17 100644 --- a/bundle/config/mutator/resolve_job_run_file_triggers.go +++ b/bundle/config/mutator/resolve_job_run_file_triggers.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "fmt" "io" + "maps" "os" "path/filepath" "strings" @@ -43,9 +44,7 @@ func (*resolveJobRunFileTriggers) Apply(_ context.Context, b *bundle.Bundle) dia path := fmt.Sprintf("resources.job_runs.%s.lifecycle.triggers[%d].on_file_change", name, i) hashes, d := resolveFileTrigger(b, path, strings.TrimSpace(*t.OnFileChange)) diags = diags.Extend(d) - for k, v := range hashes { - out[k] = v - } + maps.Copy(out, hashes) } if len(out) == 0 { jr.ResolvedFileTriggers = nil From 43cf3cf247d32f3ab101af3a1f19e8b461ecdf81 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 19 Aug 2026 08:39:23 +0000 Subject: [PATCH 06/16] job_runs: drop filepath.Glob jargon from on_file_change schema docs --- bundle/internal/schema/annotations.yml | 2 +- bundle/schema/jsonschema.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index d48a3e2053a..1fd6b452415 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -992,7 +992,7 @@ resources: If true, re-fire the run on every bundle deploy. Incompatible with lifecycle.prevent_destroy. "on_file_change": "description": |- - Path or glob under the sync root (filepath.Glob; `**` is not supported). Re-fire the run when a matched file's content hash changes, or when the set of matches appears or disappears. Incompatible with lifecycle.prevent_destroy. + Path or glob under the sync root. Re-fire the run when a matched file's content hash changes, or when the set of matches appears or disappears. Incompatible with lifecycle.prevent_destroy. "python_named_params": "description": |- PLACEHOLDER diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 9f56ca61a18..39d31e92167 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -1302,7 +1302,7 @@ "$ref": "#/$defs/bool" }, "on_file_change": { - "description": "Path or glob under the sync root (filepath.Glob; `**` is not supported). Re-fire the run when a matched file's content hash changes, or when the set of matches appears or disappears. Incompatible with lifecycle.prevent_destroy.", + "description": "Path or glob under the sync root. Re-fire the run when a matched file's content hash changes, or when the set of matches appears or disappears. Incompatible with lifecycle.prevent_destroy.", "$ref": "#/$defs/string" } }, From 47994d80e858b23a92e457f7e215fd69810ab6ed Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 19 Aug 2026 11:38:20 +0000 Subject: [PATCH 07/16] acc: refresh on_file_change goldens for resource-key progress lines --- .../bundle/resources/job_runs/on_file_change/output.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/acceptance/bundle/resources/job_runs/on_file_change/output.txt b/acceptance/bundle/resources/job_runs/on_file_change/output.txt index f356fe7fb61..a98eb3a6ca7 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change/output.txt +++ b/acceptance/bundle/resources/job_runs/on_file_change/output.txt @@ -2,8 +2,8 @@ === first deploy triggers a run >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-file-change/default/files... -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 +Output from job_runs.my_run: id=[MY_RUN_ID]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID]?o=[NUMID] +Output from job_runs.my_run: id=[MY_RUN_ID]: SUCCESS Created job_runs.my_run Created jobs.my_job Files: 5 uploaded, 0 deleted @@ -52,8 +52,8 @@ 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-file-change/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 +Output from job_runs.my_run: id=[MY_RUN_ID_2]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID_2]?o=[NUMID] +Output from job_runs.my_run: id=[MY_RUN_ID_2]: SUCCESS Recreated job_runs.my_run Files: 3 uploaded, 0 deleted Resources: 1 created, 0 changed, 1 deleted, 1 unchanged From 132ba7bee6452fc728245821aa30b5d56f3ee5eb Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 19 Aug 2026 14:50:14 +0000 Subject: [PATCH 08/16] acc: cover on_file_change globs, errors, and appear/disappear --- .../job_runs/on_file_change/out.test.toml | 1 + .../job_runs/on_file_change/output.txt | 131 +++++++++++++- .../resources/job_runs/on_file_change/script | 26 ++- .../job_runs/on_file_change/test.toml | 14 ++ .../on_file_change_errors/databricks.yml | 18 ++ .../dirs/nested/keep.txt | 1 + .../on_file_change_errors/out.test.toml | 2 + .../job_runs/on_file_change_errors/output.txt | 39 +++++ .../job_runs/on_file_change_errors/script | 12 ++ .../job_runs/on_file_change_errors/test.toml | 2 + .../on_file_change_glob/databricks.yml | 18 ++ .../on_file_change_glob/migrations/001.txt | 1 + .../on_file_change_glob/migrations/002.txt | 1 + .../migrations/archive/003.txt | 1 + .../on_file_change_glob/out.test.toml | 2 + .../job_runs/on_file_change_glob/output.txt | 164 ++++++++++++++++++ .../job_runs/on_file_change_glob/script | 47 +++++ .../job_runs/on_file_change_glob/test.toml | 9 + bundle/phases/plan.go | 4 +- 19 files changed, 486 insertions(+), 7 deletions(-) create mode 100644 acceptance/bundle/resources/job_runs/on_file_change/test.toml create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_errors/databricks.yml create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_errors/dirs/nested/keep.txt create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_errors/out.test.toml create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_errors/output.txt create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_errors/script create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_errors/test.toml create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_glob/databricks.yml create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/001.txt create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/002.txt create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/archive/003.txt create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_glob/out.test.toml create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_glob/script create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml diff --git a/acceptance/bundle/resources/job_runs/on_file_change/out.test.toml b/acceptance/bundle/resources/job_runs/on_file_change/out.test.toml index 0938e678987..57b0f616850 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change/out.test.toml +++ b/acceptance/bundle/resources/job_runs/on_file_change/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_file_change/output.txt b/acceptance/bundle/resources/job_runs/on_file_change/output.txt index a98eb3a6ca7..d03307b90f2 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change/output.txt +++ b/acceptance/bundle/resources/job_runs/on_file_change/output.txt @@ -6,7 +6,7 @@ Output from job_runs.my_run: id=[MY_RUN_ID]: Run URL: [DATABRICKS_URL]/jobs/[MY_ Output from job_runs.my_run: id=[MY_RUN_ID]: SUCCESS Created job_runs.my_run Created jobs.my_job -Files: 5 uploaded, 0 deleted +Files: 6 uploaded, 0 deleted Resources: 2 created, 0 changed, 0 deleted, 0 unchanged >>> read_id.py my_job @@ -50,12 +50,46 @@ recreate job_runs.my_run Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged ->>> [CLI] bundle deploy +>>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json +{ + "lifecycle": { + "action": "recreate", + "reason": "immutable", + "old": { + "triggers": { + "on_file_change": { + "seed.txt": "[FILE_HASH][0]" + } + } + }, + "new": { + "triggers": { + "on_file_change": { + "seed.txt": "[FILE_HASH][1]" + } + } + } + }, + "lifecycle.triggers.on_file_change['seed.txt']": { + "action": "recreate", + "reason": "immutable", + "old": "[FILE_HASH][0]", + "new": "[FILE_HASH][1]" + }, + "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-file-change/default/files... Output from job_runs.my_run: id=[MY_RUN_ID_2]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID_2]?o=[NUMID] Output from job_runs.my_run: id=[MY_RUN_ID_2]: SUCCESS Recreated job_runs.my_run -Files: 3 uploaded, 0 deleted +Files: 4 uploaded, 0 deleted Resources: 1 created, 0 changed, 1 deleted, 1 unchanged >>> print_requests.py --keep //jobs/runs/delete @@ -77,6 +111,97 @@ Resources: 1 created, 0 changed, 1 deleted, 1 unchanged } } +=== deleting the file warns and re-fires +>>> [CLI] bundle plan +Warning: lifecycle.triggers.on_file_change: no files match "seed.txt" + in databricks.yml:18:29 + +recreate job_runs.my_run + +Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged + +>>> changes +Warning: lifecycle.triggers.on_file_change: no files match "seed.txt" + in databricks.yml:18:29 + +{ + "lifecycle": { + "action": "recreate", + "reason": "immutable", + "old": { + "triggers": { + "on_file_change": { + "seed.txt": "[FILE_HASH][1]" + } + } + }, + "new": { + "triggers": { + "on_file_change": { + "seed.txt": "" + } + } + } + }, + "lifecycle.triggers.on_file_change['seed.txt']": { + "action": "recreate", + "reason": "immutable", + "old": "[FILE_HASH][1]", + "new": "" + }, + "result_state": { + "action": "skip", + "reason": "remote_already_set", + "new": "SUCCESS", + "remote": "SUCCESS" + } +} + +>>> [CLI] bundle deploy +Warning: lifecycle.triggers.on_file_change: no files match "seed.txt" + in databricks.yml:18:29 + +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-file-change/default/files... +Output from job_runs.my_run: id=[MY_RUN_ID_3]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID_3]?o=[NUMID] +Output from job_runs.my_run: id=[MY_RUN_ID_3]: SUCCESS +Recreated job_runs.my_run +Files: 3 uploaded, 1 deleted +Resources: 1 created, 0 changed, 1 deleted, 1 unchanged + +>>> print_requests.py //jobs/run-now +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[UUID]", + "job_id": [MY_JOB_ID] + } +} + +=== restoring the file re-fires again +>>> [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-file-change/default/files... +Output from job_runs.my_run: id=[MY_RUN_ID_4]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID_4]?o=[NUMID] +Output from job_runs.my_run: id=[MY_RUN_ID_4]: SUCCESS +Recreated job_runs.my_run +Files: 3 uploaded, 1 deleted +Resources: 1 created, 0 changed, 1 deleted, 1 unchanged + +>>> print_requests.py //jobs/run-now +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[UUID]", + "job_id": [MY_JOB_ID] + } +} + === removing on_file_change rewrites state without a run >>> [CLI] bundle plan update job_runs.my_run diff --git a/acceptance/bundle/resources/job_runs/on_file_change/script b/acceptance/bundle/resources/job_runs/on_file_change/script index 0506dd5e106..fd1e8800c42 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change/script +++ b/acceptance/bundle/resources/job_runs/on_file_change/script @@ -4,6 +4,10 @@ cleanup() { } trap cleanup EXIT +changes() { + $CLI bundle plan -o json | jq '.plan["resources.job_runs.my_run"].changes' +} + title "first deploy triggers a run" trace $CLI bundle deploy trace read_id.py my_job @@ -25,11 +29,31 @@ trace print_requests.py //jobs/run-now title "editing the file re-fires" update_file.py seed.txt "v1" "v2" trace $CLI bundle plan -trace $CLI bundle deploy +# Save the plan so the READPLAN=1 variant deploys the hashes computed here. +# The deploy is 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 trace print_requests.py --keep //jobs/runs/delete trace print_requests.py //jobs/run-now +title "deleting the file warns and re-fires" +mv seed.txt tmp.seed.txt +trace $CLI bundle plan +trace changes +trace $CLI bundle deploy +read_id.py my_run > /dev/null +trace print_requests.py //jobs/run-now + +title "restoring the file re-fires again" +mv tmp.seed.txt seed.txt +trace $CLI bundle plan +trace $CLI bundle deploy +read_id.py my_run > /dev/null +trace print_requests.py //jobs/run-now + title "removing on_file_change rewrites state without a run" update_file.py databricks.yml " lifecycle: triggers: diff --git a/acceptance/bundle/resources/job_runs/on_file_change/test.toml b/acceptance/bundle/resources/job_runs/on_file_change/test.toml new file mode 100644 index 00000000000..7a950e9fd0c --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change/test.toml @@ -0,0 +1,14 @@ +# Deploy both by re-planning and from a plan saved on disk, so the hashes +# computed during planning survive plan serialization. +EnvMatrix.READPLAN = ["", "1"] + +Ignore = ["tmp.plan.json", "tmp.seed.txt"] + +# Content hashes are stable but unreadable, and the parent's `\d{8,}` rule +# mangles any digit run inside them. Order=1 runs before it; Distinct keeps +# different hashes distinguishable. +[[Repls]] +Old = '[0-9a-f]{64}' +New = '[FILE_HASH]' +Order = 1 +Distinct = true diff --git a/acceptance/bundle/resources/job_runs/on_file_change_errors/databricks.yml b/acceptance/bundle/resources/job_runs/on_file_change_errors/databricks.yml new file mode 100644 index 00000000000..44c5433e52a --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_errors/databricks.yml @@ -0,0 +1,18 @@ +bundle: + name: job-runs-on-file-change-errors + +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_file_change: ../shared/*.txt diff --git a/acceptance/bundle/resources/job_runs/on_file_change_errors/dirs/nested/keep.txt b/acceptance/bundle/resources/job_runs/on_file_change_errors/dirs/nested/keep.txt new file mode 100644 index 00000000000..79c53955ef8 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_errors/dirs/nested/keep.txt @@ -0,0 +1 @@ +nested diff --git a/acceptance/bundle/resources/job_runs/on_file_change_errors/out.test.toml b/acceptance/bundle/resources/job_runs/on_file_change_errors/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_errors/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/on_file_change_errors/output.txt b/acceptance/bundle/resources/job_runs/on_file_change_errors/output.txt new file mode 100644 index 00000000000..c7d3a98e1cb --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_errors/output.txt @@ -0,0 +1,39 @@ + +=== a pattern outside the sync root is rejected +>>> musterr [CLI] bundle validate +Error: lifecycle.triggers.on_file_change: pattern "../shared/*.txt" is not under the sync root + in databricks.yml:18:29 + +Name: job-runs-on-file-change-errors +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/job-runs-on-file-change-errors/default + +Found 1 error + +=== a glob that only matches directories is rejected +>>> musterr [CLI] bundle validate +Error: lifecycle.triggers.on_file_change: pattern "dirs/*" matches no regular files + in databricks.yml:18:29 + +Name: job-runs-on-file-change-errors +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/job-runs-on-file-change-errors/default + +Found 1 error + +=== a pattern that matches nothing warns +>>> [CLI] bundle validate +Warning: lifecycle.triggers.on_file_change: no files match "missing/*.txt" + in databricks.yml:18:29 + +Name: job-runs-on-file-change-errors +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/job-runs-on-file-change-errors/default + +Found 1 warning diff --git a/acceptance/bundle/resources/job_runs/on_file_change_errors/script b/acceptance/bundle/resources/job_runs/on_file_change_errors/script new file mode 100644 index 00000000000..39596f0c772 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_errors/script @@ -0,0 +1,12 @@ +title "a pattern outside the sync root is rejected" +trace musterr $CLI bundle validate + +title "a glob that only matches directories is rejected" +# dirs/* matches dirs/nested, a directory: nothing is left to hash, so the +# trigger would silently never fire. +update_file.py databricks.yml "../shared/*.txt" "dirs/*" +trace musterr $CLI bundle validate + +title "a pattern that matches nothing warns" +update_file.py databricks.yml "dirs/*" "missing/*.txt" +trace $CLI bundle validate diff --git a/acceptance/bundle/resources/job_runs/on_file_change_errors/test.toml b/acceptance/bundle/resources/job_runs/on_file_change_errors/test.toml new file mode 100644 index 00000000000..7b88d613d4c --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_errors/test.toml @@ -0,0 +1,2 @@ +# The bundle is never deployed here, so there are no requests worth recording. +RecordRequests = false diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/databricks.yml b/acceptance/bundle/resources/job_runs/on_file_change_glob/databricks.yml new file mode 100644 index 00000000000..3b148538f38 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/databricks.yml @@ -0,0 +1,18 @@ +bundle: + name: job-runs-on-file-change-glob + +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_file_change: migrations/* diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/001.txt b/acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/001.txt new file mode 100644 index 00000000000..5626abf0f72 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/001.txt @@ -0,0 +1 @@ +one diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/002.txt b/acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/002.txt new file mode 100644 index 00000000000..f719efd430d --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/002.txt @@ -0,0 +1 @@ +two diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/archive/003.txt b/acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/archive/003.txt new file mode 100644 index 00000000000..2bdf67abb16 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/archive/003.txt @@ -0,0 +1 @@ +three diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/out.test.toml b/acceptance/bundle/resources/job_runs/on_file_change_glob/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt b/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt new file mode 100644 index 00000000000..798b459a9bd --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt @@ -0,0 +1,164 @@ + +=== deploy hashes the matched files +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-file-change-glob/default/files... +Output from job_runs.my_run: id=[MY_RUN_ID]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID]?o=[NUMID] +Output from job_runs.my_run: id=[MY_RUN_ID]: SUCCESS +Created job_runs.my_run +Created jobs.my_job +Files: 8 uploaded, 0 deleted +Resources: 2 created, 0 changed, 0 deleted, 0 unchanged + +>>> read_id.py my_job +[MY_JOB_ID] + +>>> print_requests.py //jobs/run-now +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[UUID]", + "job_id": [MY_JOB_ID] + } +} + +=== editing one matched file re-fires +>>> [CLI] bundle plan +recreate job_runs.my_run + +Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged + +>>> changes +{ + "lifecycle": { + "action": "recreate", + "reason": "immutable", + "old": { + "triggers": { + "on_file_change": { + "migrations/001.txt": "[FILE_HASH][0]", + "migrations/002.txt": "[FILE_HASH][1]" + } + } + }, + "new": { + "triggers": { + "on_file_change": { + "migrations/001.txt": "[FILE_HASH][2]", + "migrations/002.txt": "[FILE_HASH][1]" + } + } + } + }, + "lifecycle.triggers.on_file_change['migrations/001.txt']": { + "action": "recreate", + "reason": "immutable", + "old": "[FILE_HASH][0]", + "new": "[FILE_HASH][2]" + }, + "result_state": { + "action": "skip", + "reason": "remote_already_set", + "new": "SUCCESS", + "remote": "SUCCESS" + } +} + +=== a new matching file re-fires +>>> [CLI] bundle plan +recreate job_runs.my_run + +Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged + +>>> changes +{ + "lifecycle": { + "action": "recreate", + "reason": "immutable", + "old": { + "triggers": { + "on_file_change": { + "migrations/001.txt": "[FILE_HASH][0]", + "migrations/002.txt": "[FILE_HASH][1]" + } + } + }, + "new": { + "triggers": { + "on_file_change": { + "migrations/001.txt": "[FILE_HASH][0]", + "migrations/002.txt": "[FILE_HASH][1]", + "migrations/003.txt": "[FILE_HASH][3]" + } + } + } + }, + "lifecycle.triggers.on_file_change['migrations/003.txt']": { + "action": "recreate", + "reason": "immutable", + "new": "[FILE_HASH][3]" + }, + "result_state": { + "action": "skip", + "reason": "remote_already_set", + "new": "SUCCESS", + "remote": "SUCCESS" + } +} + +=== removing a matched file re-fires +>>> [CLI] bundle plan +recreate job_runs.my_run + +Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged + +>>> changes +{ + "lifecycle": { + "action": "recreate", + "reason": "immutable", + "old": { + "triggers": { + "on_file_change": { + "migrations/001.txt": "[FILE_HASH][0]", + "migrations/002.txt": "[FILE_HASH][1]" + } + } + }, + "new": { + "triggers": { + "on_file_change": { + "migrations/001.txt": "[FILE_HASH][0]" + } + } + } + }, + "lifecycle.triggers.on_file_change['migrations/002.txt']": { + "action": "recreate", + "reason": "immutable", + "old": "[FILE_HASH][1]" + }, + "result_state": { + "action": "skip", + "reason": "remote_already_set", + "new": "SUCCESS", + "remote": "SUCCESS" + } +} + +=== editing a file below the glob does not re-fire +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +=== ** is not recursive: it matches the same files as * +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +>>> [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-file-change-glob/default + +Destroy: 2 deleted diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/script b/acceptance/bundle/resources/job_runs/on_file_change_glob/script new file mode 100644 index 00000000000..8feeebcd067 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/script @@ -0,0 +1,47 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +changes() { + $CLI bundle plan -o json | jq '.plan["resources.job_runs.my_run"].changes' +} + +title "deploy hashes the matched files" +trace $CLI bundle deploy +trace read_id.py my_job +read_id.py my_run > /dev/null +trace print_requests.py //jobs/run-now + +# Each phase below plans against the state deployed above and then restores the +# tree, so every plan is compared against the same baseline. + +title "editing one matched file re-fires" +update_file.py migrations/001.txt "one" "one-edited" +trace $CLI bundle plan +trace changes +update_file.py migrations/001.txt "one-edited" "one" + +title "a new matching file re-fires" +printf 'three\n' > migrations/003.txt +trace $CLI bundle plan +trace changes +rm migrations/003.txt + +title "removing a matched file re-fires" +mv migrations/002.txt tmp.002.txt +trace $CLI bundle plan +trace changes +mv tmp.002.txt migrations/002.txt + +title "editing a file below the glob does not re-fire" +# migrations/* matches the archive directory itself, which is skipped: the glob +# does not descend into it. +update_file.py migrations/archive/003.txt "three" "three-edited" +trace $CLI bundle plan +update_file.py migrations/archive/003.txt "three-edited" "three" + +title "** is not recursive: it matches the same files as *" +update_file.py databricks.yml "migrations/*" "migrations/**" +trace $CLI bundle plan diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml b/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml new file mode 100644 index 00000000000..1c349066b94 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml @@ -0,0 +1,9 @@ +Ignore = ["tmp.002.txt"] + +# See on_file_change/test.toml: mask content hashes before the parent's +# `\d{8,}` rule can mangle them, keeping distinct hashes distinguishable. +[[Repls]] +Old = '[0-9a-f]{64}' +New = '[FILE_HASH]' +Order = 1 +Distinct = true diff --git a/bundle/phases/plan.go b/bundle/phases/plan.go index 92ca524cc4f..3db0864c2c4 100644 --- a/bundle/phases/plan.go +++ b/bundle/phases/plan.go @@ -21,8 +21,7 @@ import ( // PreDeployChecks is common set of mutators between "bundle plan" and "bundle deploy". // Note, it is not run in "bundle migrate" so it must not modify the config func PreDeployChecks(ctx context.Context, b *bundle.Bundle, isPlan bool, engine engine.EngineType) { - bundle.ApplySeqContext( - ctx, b, + bundle.ApplySeqContext(ctx, b, terraform.CheckDashboardsModifiedRemotely(isPlan, engine), resourcemutator.SecretScopeFixups(engine), deploy.StatePull(), @@ -31,7 +30,6 @@ func PreDeployChecks(ctx context.Context, b *bundle.Bundle, isPlan bool, engine mutator.ValidateLifecycleStarted(engine), mutator.ValidateCascadeOnDestroy(engine), mutator.ValidateJobRunTriggers(), - mutator.ResolveJobRunFileTriggers(), statemgmt.CheckRunningResource(engine), ) } From 75eaf87409d30c529ea0342a4a23985f9f7f121b Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 19 Aug 2026 19:58:11 +0000 Subject: [PATCH 09/16] acc: expand on_file_change glob coverage and skip ignored files Honor .gitignore and sync.exclude when hashing matches. Cover mysubdir globs, READPLAN, and keep LF on Windows so restored files hash cleanly. --- .../job_runs/on_file_change_glob/.gitignore | 2 + .../on_file_change_glob/databricks.yml | 1 + .../on_file_change_glob/mysubdir/a.txt | 1 + .../on_file_change_glob/out.test.toml | 1 + .../job_runs/on_file_change_glob/output.txt | 183 +++++++++++++++--- .../job_runs/on_file_change_glob/script | 39 ++-- .../job_runs/on_file_change_glob/test.toml | 6 +- .../mutator/resolve_job_run_file_triggers.go | 65 ++++++- .../resolve_job_run_file_triggers_test.go | 40 ++++ 9 files changed, 287 insertions(+), 51 deletions(-) create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_glob/.gitignore create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_glob/mysubdir/a.txt diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/.gitignore b/acceptance/bundle/resources/job_runs/on_file_change_glob/.gitignore new file mode 100644 index 00000000000..57433d43f47 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/.gitignore @@ -0,0 +1,2 @@ +# Matches migrations/* but sync skips it, so a content edit must not re-fire. +migrations/ignored.txt diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/databricks.yml b/acceptance/bundle/resources/job_runs/on_file_change_glob/databricks.yml index 3b148538f38..d47fcf44049 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/databricks.yml +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/databricks.yml @@ -16,3 +16,4 @@ resources: lifecycle: triggers: - on_file_change: migrations/* + - on_file_change: mysubdir/*.txt diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/mysubdir/a.txt b/acceptance/bundle/resources/job_runs/on_file_change_glob/mysubdir/a.txt new file mode 100644 index 00000000000..62e0af52c19 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/mysubdir/a.txt @@ -0,0 +1 @@ +sub diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/out.test.toml b/acceptance/bundle/resources/job_runs/on_file_change_glob/out.test.toml index 0938e678987..57b0f616850 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/out.test.toml +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/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_file_change_glob/output.txt b/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt index 798b459a9bd..d424482b961 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt @@ -6,7 +6,7 @@ Output from job_runs.my_run: id=[MY_RUN_ID]: Run URL: [DATABRICKS_URL]/jobs/[MY_ Output from job_runs.my_run: id=[MY_RUN_ID]: SUCCESS Created job_runs.my_run Created jobs.my_job -Files: 8 uploaded, 0 deleted +Files: 10 uploaded, 0 deleted Resources: 2 created, 0 changed, 0 deleted, 0 unchanged >>> read_id.py my_job @@ -23,12 +23,7 @@ Resources: 2 created, 0 changed, 0 deleted, 0 unchanged } === editing one matched file re-fires ->>> [CLI] bundle plan -recreate job_runs.my_run - -Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged - ->>> changes +>>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json { "lifecycle": { "action": "recreate", @@ -37,15 +32,17 @@ Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged "triggers": { "on_file_change": { "migrations/001.txt": "[FILE_HASH][0]", - "migrations/002.txt": "[FILE_HASH][1]" + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" } } }, "new": { "triggers": { "on_file_change": { - "migrations/001.txt": "[FILE_HASH][2]", - "migrations/002.txt": "[FILE_HASH][1]" + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" } } } @@ -54,7 +51,7 @@ Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged "action": "recreate", "reason": "immutable", "old": "[FILE_HASH][0]", - "new": "[FILE_HASH][2]" + "new": "[FILE_HASH][3]" }, "result_state": { "action": "skip", @@ -64,13 +61,35 @@ Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged } } -=== a new matching file re-fires ->>> [CLI] bundle plan -recreate job_runs.my_run +=== bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-file-change-glob/default/files... +Output from job_runs.my_run: id=[MY_RUN_ID_2]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID_2]?o=[NUMID] +Output from job_runs.my_run: id=[MY_RUN_ID_2]: SUCCESS +Recreated job_runs.my_run +Files: 4 uploaded, 0 deleted +Resources: 1 created, 0 changed, 1 deleted, 1 unchanged + +>>> print_requests.py --keep //jobs/runs/delete +{ + "method": "POST", + "path": "/api/2.2/jobs/runs/delete", + "body": { + "run_id": [MY_RUN_ID] + } +} -Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged +>>> print_requests.py //jobs/run-now +{ + "method": "POST", + "path": "/api/2.2/jobs/run-now", + "body": { + "idempotency_token": "[UUID]", + "job_id": [MY_JOB_ID] + } +} ->>> changes +=== editing a file in a subdirectory re-fires +>>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json { "lifecycle": { "action": "recreate", @@ -78,17 +97,58 @@ Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged "old": { "triggers": { "on_file_change": { - "migrations/001.txt": "[FILE_HASH][0]", - "migrations/002.txt": "[FILE_HASH][1]" + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" } } }, "new": { "triggers": { "on_file_change": { - "migrations/001.txt": "[FILE_HASH][0]", + "migrations/001.txt": "[FILE_HASH][3]", "migrations/002.txt": "[FILE_HASH][1]", - "migrations/003.txt": "[FILE_HASH][3]" + "mysubdir/a.txt": "[FILE_HASH][4]" + } + } + } + }, + "lifecycle.triggers.on_file_change['mysubdir/a.txt']": { + "action": "recreate", + "reason": "immutable", + "old": "[FILE_HASH][2]", + "new": "[FILE_HASH][4]" + }, + "result_state": { + "action": "skip", + "reason": "remote_already_set", + "new": "SUCCESS", + "remote": "SUCCESS" + } +} + +=== a new matching file re-fires +>>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json +{ + "lifecycle": { + "action": "recreate", + "reason": "immutable", + "old": { + "triggers": { + "on_file_change": { + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" + } + } + }, + "new": { + "triggers": { + "on_file_change": { + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "migrations/003.txt": "[FILE_HASH][5]", + "mysubdir/a.txt": "[FILE_HASH][2]" } } } @@ -96,7 +156,7 @@ Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged "lifecycle.triggers.on_file_change['migrations/003.txt']": { "action": "recreate", "reason": "immutable", - "new": "[FILE_HASH][3]" + "new": "[FILE_HASH][5]" }, "result_state": { "action": "skip", @@ -107,12 +167,7 @@ Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged } === removing a matched file re-fires ->>> [CLI] bundle plan -recreate job_runs.my_run - -Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged - ->>> changes +>>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json { "lifecycle": { "action": "recreate", @@ -120,15 +175,17 @@ Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged "old": { "triggers": { "on_file_change": { - "migrations/001.txt": "[FILE_HASH][0]", - "migrations/002.txt": "[FILE_HASH][1]" + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" } } }, "new": { "triggers": { "on_file_change": { - "migrations/001.txt": "[FILE_HASH][0]" + "migrations/001.txt": "[FILE_HASH][3]", + "mysubdir/a.txt": "[FILE_HASH][2]" } } } @@ -147,12 +204,74 @@ Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged } === editing a file below the glob does not re-fire +>>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json +{ + "lifecycle": { + "action": "skip", + "reason": "missing_in_remote", + "old": { + "triggers": { + "on_file_change": { + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" + } + } + }, + "new": { + "triggers": { + "on_file_change": { + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" + } + } + } + }, + "result_state": { + "action": "skip", + "reason": "remote_already_set", + "new": "SUCCESS", + "remote": "SUCCESS" + } +} + +=== editing a gitignored file does not re-fire >>> [CLI] bundle plan Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged === ** is not recursive: it matches the same files as * ->>> [CLI] bundle plan -Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged +>>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json +{ + "lifecycle": { + "action": "skip", + "reason": "missing_in_remote", + "old": { + "triggers": { + "on_file_change": { + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" + } + } + }, + "new": { + "triggers": { + "on_file_change": { + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" + } + } + } + }, + "result_state": { + "action": "skip", + "reason": "remote_already_set", + "new": "SUCCESS", + "remote": "SUCCESS" + } +} >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/script b/acceptance/bundle/resources/job_runs/on_file_change_glob/script index 8feeebcd067..983cb9ac401 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/script +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/script @@ -4,8 +4,10 @@ cleanup() { } trap cleanup EXIT +# Plan once, print JSON changes, leave tmp.plan.json for readplanarg. changes() { - $CLI bundle plan -o json | jq '.plan["resources.job_runs.my_run"].changes' + $CLI bundle plan -o json > tmp.plan.json + trace jq '.plan["resources.job_runs.my_run"].changes' tmp.plan.json } title "deploy hashes the matched files" @@ -14,34 +16,45 @@ trace read_id.py my_job read_id.py my_run > /dev/null trace print_requests.py //jobs/run-now -# Each phase below plans against the state deployed above and then restores the -# tree, so every plan is compared against the same baseline. - title "editing one matched file re-fires" update_file.py migrations/001.txt "one" "one-edited" -trace $CLI bundle plan -trace changes -update_file.py migrations/001.txt "one-edited" "one" +changes +# The deploy is not traced: readplanarg makes the command line differ per variant. +title "bundle deploy\n" +$CLI bundle deploy $(readplanarg tmp.plan.json) +read_id.py my_run > /dev/null +trace print_requests.py --keep //jobs/runs/delete +trace print_requests.py //jobs/run-now + +title "editing a file in a subdirectory re-fires" +# printf keeps LF on Windows, where update_file.py's text-mode rewrite would +# restore CRLF and leave the file hashing differently than the deploy above. +printf 'sub-edited\n' > mysubdir/a.txt +changes +printf 'sub\n' > mysubdir/a.txt title "a new matching file re-fires" printf 'three\n' > migrations/003.txt -trace $CLI bundle plan -trace changes +changes rm migrations/003.txt title "removing a matched file re-fires" mv migrations/002.txt tmp.002.txt -trace $CLI bundle plan -trace changes +changes mv tmp.002.txt migrations/002.txt title "editing a file below the glob does not re-fire" # migrations/* matches the archive directory itself, which is skipped: the glob # does not descend into it. update_file.py migrations/archive/003.txt "three" "three-edited" -trace $CLI bundle plan +changes update_file.py migrations/archive/003.txt "three-edited" "three" +title "editing a gitignored file does not re-fire" +update_file.py migrations/ignored.txt "ignored" "ignored-edited" +trace $CLI bundle plan +update_file.py migrations/ignored.txt "ignored-edited" "ignored" + title "** is not recursive: it matches the same files as *" update_file.py databricks.yml "migrations/*" "migrations/**" -trace $CLI bundle plan +changes diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml b/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml index 1c349066b94..3516572564d 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml @@ -1,4 +1,8 @@ -Ignore = ["tmp.002.txt"] +# Deploy both by re-planning and from a plan saved on disk, so the hashes +# computed during planning survive plan serialization. +EnvMatrix.READPLAN = ["", "1"] + +Ignore = ["tmp.plan.json", "tmp.002.txt"] # See on_file_change/test.toml: mask content hashes before the parent's # `\d{8,}` rule can mangle them, keeping distinct hashes distinguishable. diff --git a/bundle/config/mutator/resolve_job_run_file_triggers.go b/bundle/config/mutator/resolve_job_run_file_triggers.go index ead17c59e17..d74b9841c57 100644 --- a/bundle/config/mutator/resolve_job_run_file_triggers.go +++ b/bundle/config/mutator/resolve_job_run_file_triggers.go @@ -13,6 +13,7 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/libs/diag" + libsync "github.com/databricks/cli/libs/sync" ) // missingFileHash marks a pattern with no matching file so appear/disappear recreates. @@ -30,8 +31,13 @@ func (*resolveJobRunFileTriggers) Name() string { return "ResolveJobRunFileTriggers" } -func (*resolveJobRunFileTriggers) Apply(_ context.Context, b *bundle.Bundle) diag.Diagnostics { +func (*resolveJobRunFileTriggers) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics { var diags diag.Diagnostics + syncable, d := syncableRelPaths(ctx, b) + if d.HasError() { + return d + } + diags = diags.Extend(d) for name, jr := range b.Config.Resources.JobRuns { if jr == nil || jr.Lifecycle == nil { continue @@ -42,7 +48,7 @@ func (*resolveJobRunFileTriggers) Apply(_ context.Context, b *bundle.Bundle) dia continue } path := fmt.Sprintf("resources.job_runs.%s.lifecycle.triggers[%d].on_file_change", name, i) - hashes, d := resolveFileTrigger(b, path, strings.TrimSpace(*t.OnFileChange)) + hashes, d := resolveFileTrigger(b, path, strings.TrimSpace(*t.OnFileChange), syncable) diags = diags.Extend(d) maps.Copy(out, hashes) } @@ -55,7 +61,42 @@ func (*resolveJobRunFileTriggers) Apply(_ context.Context, b *bundle.Bundle) dia return diags } -func resolveFileTrigger(b *bundle.Bundle, loc, pattern string) (map[string]string, diag.Diagnostics) { +// syncableRelPaths is the set of relative paths sync would upload. +func syncableRelPaths(ctx context.Context, b *bundle.Bundle) (map[string]struct{}, diag.Diagnostics) { + var diags diag.Diagnostics + needs := false + for _, jr := range b.Config.Resources.JobRuns { + if jr != nil && jr.HasOnFileChange() { + needs = true + break + } + } + if !needs { + return nil, diags + } + + fl, err := libsync.NewFileList(ctx, b.WorktreeRoot, b.SyncRoot, b.Config.Sync.Paths, b.Config.Sync.Include, b.Config.Sync.Exclude) + if err != nil { + return nil, diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: list sync files: %s", err), + }) + } + files, err := fl.Files(ctx) + if err != nil { + return nil, diags.Append(diag.Diagnostic{ + Severity: diag.Error, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: list sync files: %s", err), + }) + } + out := make(map[string]struct{}, len(files)) + for _, f := range files { + out[filepath.ToSlash(f.Relative)] = struct{}{} + } + return out, diags +} + +func resolveFileTrigger(b *bundle.Bundle, loc, pattern string, syncable map[string]struct{}) (map[string]string, diag.Diagnostics) { var diags diag.Diagnostics out := make(map[string]string) localPattern := filepath.FromSlash(pattern) @@ -84,6 +125,7 @@ func resolveFileTrigger(b *bundle.Bundle, loc, pattern string) (map[string]strin }) } regularMatches := 0 + ignoredMatches := 0 sawNonRegular := false for _, match := range matches { info, err := os.Stat(match) @@ -99,7 +141,6 @@ func resolveFileTrigger(b *bundle.Bundle, loc, pattern string) (map[string]strin sawNonRegular = true continue } - regularMatches++ rel, err := filepath.Rel(b.SyncRootPath, match) if err != nil || !filepath.IsLocal(rel) { diags = diags.Append(diag.Diagnostic{ @@ -109,6 +150,12 @@ func resolveFileTrigger(b *bundle.Bundle, loc, pattern string) (map[string]strin }) continue } + // Same membership as sync: .gitignore and sync.exclude drop a glob match. + if _, ok := syncable[filepath.ToSlash(rel)]; !ok { + ignoredMatches++ + continue + } + regularMatches++ hash, err := hashFile(match) if err != nil { diags = diags.Append(diag.Diagnostic{ @@ -122,13 +169,21 @@ func resolveFileTrigger(b *bundle.Bundle, loc, pattern string) (map[string]strin } // A directory-only match would otherwise leave ResolvedFileTriggers empty // and silently disarm the trigger while config still sets on_file_change. - if regularMatches == 0 && sawNonRegular { + if regularMatches == 0 && sawNonRegular && ignoredMatches == 0 { diags = diags.Append(diag.Diagnostic{ Severity: diag.Error, Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: pattern %q matches no regular files", pattern), Locations: b.Config.GetLocations(loc), }) } + if len(out) == 0 && ignoredMatches > 0 { + out[filepath.ToSlash(pattern)] = missingFileHash + diags = diags.Append(diag.Diagnostic{ + Severity: diag.Warning, + Summary: fmt.Sprintf("lifecycle.triggers.on_file_change: no files match %q", pattern), + Locations: b.Config.GetLocations(loc), + }) + } return out, diags } diff --git a/bundle/config/mutator/resolve_job_run_file_triggers_test.go b/bundle/config/mutator/resolve_job_run_file_triggers_test.go index 3ec663c89bd..344fed25657 100644 --- a/bundle/config/mutator/resolve_job_run_file_triggers_test.go +++ b/bundle/config/mutator/resolve_job_run_file_triggers_test.go @@ -12,6 +12,7 @@ import ( "github.com/databricks/cli/bundle/config/mutator" "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/libs/diag" + "github.com/databricks/cli/libs/vfs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -85,7 +86,10 @@ func TestResolveJobRunFileTriggers(t *testing.T) { patB := "subdir/*.py" b := &bundle.Bundle{ SyncRootPath: dir, + SyncRoot: vfs.MustNew(dir), + WorktreeRoot: vfs.MustNew(dir), Config: config.Root{ + Sync: config.Sync{Paths: []string{"."}}, Resources: config.Resources{ JobRuns: map[string]*resources.JobRun{ "my_run": { @@ -135,6 +139,38 @@ func TestResolveJobRunFileTriggers(t *testing.T) { assert.Empty(t, b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers) }) + t.Run("skips gitignored files", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("skip.txt\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("keep"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "skip.txt"), []byte("skip"), 0o644)) + + b := bundleWithFileTrigger(dir, "*.txt") + diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) + require.False(t, diags.HasError()) + + hashes := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers + require.Len(t, hashes, 1) + assert.Equal(t, contentHash("keep"), hashes["keep.txt"]) + assert.NotContains(t, hashes, "skip.txt") + }) + + t.Run("skips sync.exclude files", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("keep"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "skip.txt"), []byte("skip"), 0o644)) + + b := bundleWithFileTrigger(dir, "*.txt") + b.Config.Sync.Exclude = []string{"skip.txt"} + diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) + require.False(t, diags.HasError()) + + hashes := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers + require.Len(t, hashes, 1) + assert.Equal(t, contentHash("keep"), hashes["keep.txt"]) + assert.NotContains(t, hashes, "skip.txt") + }) + t.Run("trims pattern whitespace", func(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "seed.txt"), []byte("v1"), 0o644)) @@ -148,9 +184,13 @@ func TestResolveJobRunFileTriggers(t *testing.T) { } func bundleWithFileTrigger(syncRoot, pattern string) *bundle.Bundle { + root := vfs.MustNew(syncRoot) return &bundle.Bundle{ SyncRootPath: syncRoot, + SyncRoot: root, + WorktreeRoot: root, Config: config.Root{ + Sync: config.Sync{Paths: []string{"."}}, Resources: config.Resources{ JobRuns: map[string]*resources.JobRun{ "my_run": { From 4e43352cf8a38d3f3535e5895b21191288a6e359 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Wed, 19 Aug 2026 20:10:03 +0000 Subject: [PATCH 10/16] acc: force-add gitignored on_file_change fixture The glob test edits migrations/ignored.txt, but the test .gitignore excluded it from the commit so CI clones would miss the file. --- .../job_runs/on_file_change_glob/migrations/ignored.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/ignored.txt diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/ignored.txt b/acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/ignored.txt new file mode 100644 index 00000000000..ea10ec85c10 --- /dev/null +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/migrations/ignored.txt @@ -0,0 +1 @@ +ignored From a7a3275042c4371401da2746fa10690951f9be6c Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 20 Aug 2026 08:11:43 +0000 Subject: [PATCH 11/16] acc: drop READPLAN from the on_file_change glob test Plan serialization is already covered by on_file_change; the glob cases only assert the hash map. --- .../resources/job_runs/on_file_change_glob/out.test.toml | 1 - .../bundle/resources/job_runs/on_file_change_glob/output.txt | 2 +- .../bundle/resources/job_runs/on_file_change_glob/script | 5 +---- .../bundle/resources/job_runs/on_file_change_glob/test.toml | 4 ---- 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/out.test.toml b/acceptance/bundle/resources/job_runs/on_file_change_glob/out.test.toml index 57b0f616850..0938e678987 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/out.test.toml +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/out.test.toml @@ -1,3 +1,2 @@ Cloud = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] -EnvMatrix.READPLAN = ["", "1"] diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt b/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt index d424482b961..bd7d08557f5 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt @@ -61,7 +61,7 @@ Resources: 2 created, 0 changed, 0 deleted, 0 unchanged } } -=== bundle deploy +>>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-file-change-glob/default/files... Output from job_runs.my_run: id=[MY_RUN_ID_2]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID_2]?o=[NUMID] Output from job_runs.my_run: id=[MY_RUN_ID_2]: SUCCESS diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/script b/acceptance/bundle/resources/job_runs/on_file_change_glob/script index 983cb9ac401..23390d23a55 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/script +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/script @@ -4,7 +4,6 @@ cleanup() { } trap cleanup EXIT -# Plan once, print JSON changes, leave tmp.plan.json for readplanarg. changes() { $CLI bundle plan -o json > tmp.plan.json trace jq '.plan["resources.job_runs.my_run"].changes' tmp.plan.json @@ -19,9 +18,7 @@ trace print_requests.py //jobs/run-now title "editing one matched file re-fires" update_file.py migrations/001.txt "one" "one-edited" changes -# The deploy is not traced: readplanarg makes the command line differ per variant. -title "bundle deploy\n" -$CLI bundle deploy $(readplanarg tmp.plan.json) +trace $CLI bundle deploy read_id.py my_run > /dev/null trace print_requests.py --keep //jobs/runs/delete trace print_requests.py //jobs/run-now diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml b/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml index 3516572564d..d372fd54eae 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml @@ -1,7 +1,3 @@ -# Deploy both by re-planning and from a plan saved on disk, so the hashes -# computed during planning survive plan serialization. -EnvMatrix.READPLAN = ["", "1"] - Ignore = ["tmp.plan.json", "tmp.002.txt"] # See on_file_change/test.toml: mask content hashes before the parent's From 786a2d260ff7ca2e29236ff06e7b0384a8c403ff Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 20 Aug 2026 12:34:44 +0000 Subject: [PATCH 12/16] acc: restore READPLAN on the on_file_change glob test Cover deploying glob hashes from a saved plan, not only from a live re-plan. --- .../resources/job_runs/on_file_change_glob/out.test.toml | 1 + .../bundle/resources/job_runs/on_file_change_glob/output.txt | 2 +- .../bundle/resources/job_runs/on_file_change_glob/script | 5 ++++- .../bundle/resources/job_runs/on_file_change_glob/test.toml | 4 ++++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/out.test.toml b/acceptance/bundle/resources/job_runs/on_file_change_glob/out.test.toml index 0938e678987..57b0f616850 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/out.test.toml +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/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_file_change_glob/output.txt b/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt index bd7d08557f5..d424482b961 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt @@ -61,7 +61,7 @@ Resources: 2 created, 0 changed, 0 deleted, 0 unchanged } } ->>> [CLI] bundle deploy +=== bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/job-runs-on-file-change-glob/default/files... Output from job_runs.my_run: id=[MY_RUN_ID_2]: Run URL: [DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID_2]?o=[NUMID] Output from job_runs.my_run: id=[MY_RUN_ID_2]: SUCCESS diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/script b/acceptance/bundle/resources/job_runs/on_file_change_glob/script index 23390d23a55..983cb9ac401 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/script +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/script @@ -4,6 +4,7 @@ cleanup() { } trap cleanup EXIT +# Plan once, print JSON changes, leave tmp.plan.json for readplanarg. changes() { $CLI bundle plan -o json > tmp.plan.json trace jq '.plan["resources.job_runs.my_run"].changes' tmp.plan.json @@ -18,7 +19,9 @@ trace print_requests.py //jobs/run-now title "editing one matched file re-fires" update_file.py migrations/001.txt "one" "one-edited" changes -trace $CLI bundle deploy +# The deploy is not traced: readplanarg makes the command line differ per variant. +title "bundle deploy\n" +$CLI bundle deploy $(readplanarg tmp.plan.json) read_id.py my_run > /dev/null trace print_requests.py --keep //jobs/runs/delete trace print_requests.py //jobs/run-now diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml b/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml index d372fd54eae..3516572564d 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml @@ -1,3 +1,7 @@ +# Deploy both by re-planning and from a plan saved on disk, so the hashes +# computed during planning survive plan serialization. +EnvMatrix.READPLAN = ["", "1"] + Ignore = ["tmp.plan.json", "tmp.002.txt"] # See on_file_change/test.toml: mask content hashes before the parent's From 76c0d7082428fa40f77ed04863dbf3153fbf24e1 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 20 Aug 2026 14:08:42 +0000 Subject: [PATCH 13/16] job_runs: address on_file_change review comments Keep the gitignore fixture as dot_gitignore so it does not affect the repo while developing, drop unit tests that duplicate acceptance coverage, and allocate trigger state once when a trigger is armed. --- .../{.gitignore => dot_gitignore} | 0 .../job_runs/on_file_change_glob/script | 3 + .../job_runs/on_file_change_glob/test.toml | 2 +- .../resolve_job_run_file_triggers_test.go | 139 +----------------- bundle/direct/dresources/job_run.go | 23 +-- bundle/direct/dresources/job_run_test.go | 36 ++--- 6 files changed, 23 insertions(+), 180 deletions(-) rename acceptance/bundle/resources/job_runs/on_file_change_glob/{.gitignore => dot_gitignore} (100%) diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/.gitignore b/acceptance/bundle/resources/job_runs/on_file_change_glob/dot_gitignore similarity index 100% rename from acceptance/bundle/resources/job_runs/on_file_change_glob/.gitignore rename to acceptance/bundle/resources/job_runs/on_file_change_glob/dot_gitignore diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/script b/acceptance/bundle/resources/job_runs/on_file_change_glob/script index 983cb9ac401..dd737f242b5 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/script +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/script @@ -1,3 +1,6 @@ +# Fixture is named dot_gitignore so a real .gitignore does not affect git while developing. +mv dot_gitignore .gitignore + cleanup() { trace $CLI bundle destroy --auto-approve rm -f out.requests.txt diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml b/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml index 3516572564d..98c98631e5b 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/test.toml @@ -2,7 +2,7 @@ # computed during planning survive plan serialization. EnvMatrix.READPLAN = ["", "1"] -Ignore = ["tmp.plan.json", "tmp.002.txt"] +Ignore = ["tmp.plan.json", "tmp.002.txt", ".gitignore"] # See on_file_change/test.toml: mask content hashes before the parent's # `\d{8,}` rule can mangle them, keeping distinct hashes distinguishable. diff --git a/bundle/config/mutator/resolve_job_run_file_triggers_test.go b/bundle/config/mutator/resolve_job_run_file_triggers_test.go index 344fed25657..1cc54eecf4f 100644 --- a/bundle/config/mutator/resolve_job_run_file_triggers_test.go +++ b/bundle/config/mutator/resolve_job_run_file_triggers_test.go @@ -11,14 +11,13 @@ import ( "github.com/databricks/cli/bundle/config" "github.com/databricks/cli/bundle/config/mutator" "github.com/databricks/cli/bundle/config/resources" - "github.com/databricks/cli/libs/diag" "github.com/databricks/cli/libs/vfs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestResolveJobRunFileTriggers(t *testing.T) { - t.Run("matches files and fills hashes", func(t *testing.T) { + t.Run("hashes file contents with sha256", func(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), []byte("world"), 0o644)) @@ -35,142 +34,6 @@ func TestResolveJobRunFileTriggers(t *testing.T) { assert.Equal(t, contentHash("world"), hashes["b.txt"]) }) - t.Run("no matches warns and stores empty hash", func(t *testing.T) { - dir := t.TempDir() - pattern := "missing.txt" - b := bundleWithFileTrigger(dir, pattern) - - diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) - require.False(t, diags.HasError()) - require.Len(t, diags, 1) - assert.Equal(t, diag.Warning, diags[0].Severity) - assert.Contains(t, diags[0].Summary, `no files match "missing.txt"`) - - hashes := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers - require.Len(t, hashes, 1) - assert.Empty(t, hashes["missing.txt"]) - }) - - t.Run("no file triggers is a no-op", func(t *testing.T) { - dir := t.TempDir() - on := true - b := &bundle.Bundle{ - SyncRootPath: dir, - Config: config.Root{ - Resources: config.Resources{ - JobRuns: map[string]*resources.JobRun{ - "my_run": { - Lifecycle: &resources.JobRunLifecycle{ - Triggers: []resources.JobRunTrigger{ - {OnBundleDeploy: &on}, - }, - }, - }, - }, - }, - }, - } - - diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) - assert.Empty(t, diags) - assert.Nil(t, b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers) - }) - - t.Run("multiple patterns merge into one map", func(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("aaa"), 0o644)) - require.NoError(t, os.MkdirAll(filepath.Join(dir, "subdir"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "subdir", "x.py"), []byte("bbb"), 0o644)) - - patA := "a.txt" - patB := "subdir/*.py" - b := &bundle.Bundle{ - SyncRootPath: dir, - SyncRoot: vfs.MustNew(dir), - WorktreeRoot: vfs.MustNew(dir), - Config: config.Root{ - Sync: config.Sync{Paths: []string{"."}}, - Resources: config.Resources{ - JobRuns: map[string]*resources.JobRun{ - "my_run": { - Lifecycle: &resources.JobRunLifecycle{ - Triggers: []resources.JobRunTrigger{ - {OnFileChange: &patA}, - {OnFileChange: &patB}, - }, - }, - }, - }, - }, - }, - } - - diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) - require.False(t, diags.HasError()) - - hashes := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers - require.Len(t, hashes, 2) - assert.Equal(t, contentHash("aaa"), hashes["a.txt"]) - assert.Equal(t, contentHash("bbb"), hashes["subdir/x.py"]) - }) - - t.Run("pattern outside sync root is an error", func(t *testing.T) { - dir := t.TempDir() - pattern := "../outside.txt" - b := bundleWithFileTrigger(dir, pattern) - - diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) - require.True(t, diags.HasError()) - require.Len(t, diags, 1) - assert.Contains(t, diags[0].Summary, `not under the sync root`) - assert.Empty(t, b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers) - }) - - t.Run("directory-only match is an error", func(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(dir, "migrations"), 0o755)) - pattern := "migrations" - b := bundleWithFileTrigger(dir, pattern) - - diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) - require.True(t, diags.HasError()) - require.Len(t, diags, 1) - assert.Contains(t, diags[0].Summary, `matches no regular files`) - assert.Empty(t, b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers) - }) - - t.Run("skips gitignored files", func(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("skip.txt\n"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("keep"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "skip.txt"), []byte("skip"), 0o644)) - - b := bundleWithFileTrigger(dir, "*.txt") - diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) - require.False(t, diags.HasError()) - - hashes := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers - require.Len(t, hashes, 1) - assert.Equal(t, contentHash("keep"), hashes["keep.txt"]) - assert.NotContains(t, hashes, "skip.txt") - }) - - t.Run("skips sync.exclude files", func(t *testing.T) { - dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("keep"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "skip.txt"), []byte("skip"), 0o644)) - - b := bundleWithFileTrigger(dir, "*.txt") - b.Config.Sync.Exclude = []string{"skip.txt"} - diags := bundle.Apply(t.Context(), b, mutator.ResolveJobRunFileTriggers()) - require.False(t, diags.HasError()) - - hashes := b.Config.Resources.JobRuns["my_run"].ResolvedFileTriggers - require.Len(t, hashes, 1) - assert.Equal(t, contentHash("keep"), hashes["keep.txt"]) - assert.NotContains(t, hashes, "skip.txt") - }) - t.Run("trims pattern whitespace", func(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "seed.txt"), []byte("v1"), 0o644)) diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index c7bc11d62a0..ab85857e731 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -110,25 +110,14 @@ func (*ResourceJobRun) PrepareState(input *resources.JobRun) *JobRunState { ResultState: jobs.RunResultStateSuccess, Lifecycle: nil, } - var triggers *JobRunTriggersState - if input.HasOnBundleDeploy() { - triggers = &JobRunTriggersState{ - OnBundleDeploy: uuid.NewString(), - OnFileChange: nil, - } + if !input.HasOnBundleDeploy() && len(input.ResolvedFileTriggers) == 0 { + return state } - if len(input.ResolvedFileTriggers) > 0 { - if triggers == nil { - triggers = &JobRunTriggersState{ - OnBundleDeploy: "", - OnFileChange: nil, - } - } - triggers.OnFileChange = input.ResolvedFileTriggers - } - if triggers != nil { - state.Lifecycle = &JobRunLifecycleState{Triggers: triggers} + triggers := &JobRunTriggersState{OnFileChange: input.ResolvedFileTriggers} + if input.HasOnBundleDeploy() { + triggers.OnBundleDeploy = uuid.NewString() } + state.Lifecycle = &JobRunLifecycleState{Triggers: triggers} return state } diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index 2c5815d3a20..72de912e9d6 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -354,36 +354,24 @@ 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.Lifecycle) - }) - - 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.Lifecycle) - require.NotNil(t, first.Lifecycle.Triggers) - assert.NotEmpty(t, first.Lifecycle.Triggers.OnBundleDeploy) + on := true + input := &resources.JobRun{ + Lifecycle: &resources.JobRunLifecycle{ + Triggers: []resources.JobRunTrigger{{OnBundleDeploy: &on}}, + }, + } + first := (&ResourceJobRun{}).PrepareState(input) + 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.Lifecycle.Triggers.OnBundleDeploy, second.Lifecycle.Triggers.OnBundleDeploy) - }) + second := (&ResourceJobRun{}).PrepareState(input) + assert.NotEqual(t, first.Lifecycle.Triggers.OnBundleDeploy, second.Lifecycle.Triggers.OnBundleDeploy) } func TestJobRunPrepareStateOnFileChange(t *testing.T) { hashes := map[string]string{"a.txt": "abc"} - t.Run("unset", func(t *testing.T) { - state := (&ResourceJobRun{}).PrepareState(&resources.JobRun{}) - assert.Nil(t, state.Lifecycle) - }) - t.Run("armed", func(t *testing.T) { state := (&ResourceJobRun{}).PrepareState(&resources.JobRun{ ResolvedFileTriggers: hashes, From e1fbadc2cbab7c6cf605df8ec9be6134be88994a Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 20 Aug 2026 14:16:35 +0000 Subject: [PATCH 14/16] job_runs: list every trigger field to satisfy exhaustruct The previous simplification omitted OnBundleDeploy from the struct literal, which the linter rejects. --- bundle/direct/dresources/job_run.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index ab85857e731..63ed81cb78a 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -113,11 +113,16 @@ func (*ResourceJobRun) PrepareState(input *resources.JobRun) *JobRunState { if !input.HasOnBundleDeploy() && len(input.ResolvedFileTriggers) == 0 { return state } - triggers := &JobRunTriggersState{OnFileChange: input.ResolvedFileTriggers} + onBundleDeploy := "" if input.HasOnBundleDeploy() { - triggers.OnBundleDeploy = uuid.NewString() + onBundleDeploy = uuid.NewString() + } + state.Lifecycle = &JobRunLifecycleState{ + Triggers: &JobRunTriggersState{ + OnBundleDeploy: onBundleDeploy, + OnFileChange: input.ResolvedFileTriggers, + }, } - state.Lifecycle = &JobRunLifecycleState{Triggers: triggers} return state } From eb7c422e37ce9a7ed71ca837b47996b51836d437 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Fri, 21 Aug 2026 12:31:38 +0000 Subject: [PATCH 15/16] job_runs: keep empty nested lifecycle on state and remote Always persist the same lifecycle.triggers shape so trigger fingerprints compare at the same paths. Wrap on_file_change hashes in files so dropping the trigger updates instead of recreating. --- acceptance/bundle/refschema/out.fields.txt | 11 +- .../resources/job_runs/failed_run/output.txt | 10 ++ .../job_runs/interrupted_run/output.txt | 10 ++ .../job_runs/on_bundle_deploy/output.txt | 22 +-- .../job_runs/on_file_change/output.txt | 32 +--- .../job_runs/on_file_change_glob/output.txt | 144 ++++++------------ .../resources/job_runs/redeploy/output.txt | 10 ++ bundle/direct/dresources/job_run.go | 101 ++++++++---- bundle/direct/dresources/job_run_test.go | 94 +++++++++--- bundle/direct/dresources/resources.yml | 3 + bundle/direct/dresources/type_test.go | 4 - 11 files changed, 244 insertions(+), 197 deletions(-) diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index b84842ffdc6..d6da3840c2a 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -845,15 +845,16 @@ 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 *dresources.JobRunLifecycleState REMOTE 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 *dresources.JobRunTriggersState REMOTE STATE resources.job_runs.*.lifecycle.triggers []resources.JobRunTrigger INPUT -resources.job_runs.*.lifecycle.triggers.on_bundle_deploy string STATE -resources.job_runs.*.lifecycle.triggers.on_file_change map[string]string STATE -resources.job_runs.*.lifecycle.triggers.on_file_change.* string STATE +resources.job_runs.*.lifecycle.triggers.on_bundle_deploy string REMOTE STATE +resources.job_runs.*.lifecycle.triggers.on_file_change *dresources.JobRunFileTriggerState REMOTE STATE +resources.job_runs.*.lifecycle.triggers.on_file_change.files map[string]string REMOTE STATE +resources.job_runs.*.lifecycle.triggers.on_file_change.files.* string REMOTE STATE resources.job_runs.*.lifecycle.triggers[*] resources.JobRunTrigger INPUT resources.job_runs.*.lifecycle.triggers[*].on_bundle_deploy *bool INPUT resources.job_runs.*.lifecycle.triggers[*].on_file_change *string INPUT diff --git a/acceptance/bundle/resources/job_runs/failed_run/output.txt b/acceptance/bundle/resources/job_runs/failed_run/output.txt index f8b8dbf4398..0c6bddb0d9b 100644 --- a/acceptance/bundle/resources/job_runs/failed_run/output.txt +++ b/acceptance/bundle/resources/job_runs/failed_run/output.txt @@ -27,11 +27,21 @@ Files: 7 uploaded, 0 deleted "new_state": { "value": { "job_id": [MY_JOB_ID], + "lifecycle": { + "triggers": { + "on_file_change": {} + } + }, "result_state": "SUCCESS" } }, "remote_state": { "job_id": [MY_JOB_ID], + "lifecycle": { + "triggers": { + "on_file_change": {} + } + }, "result_state": "FAILED", "run_id": [MY_RUN_ID], "run_name": "test-job-[UNIQUE_NAME]", diff --git a/acceptance/bundle/resources/job_runs/interrupted_run/output.txt b/acceptance/bundle/resources/job_runs/interrupted_run/output.txt index 25aaf20b64e..47dfb8c7617 100644 --- a/acceptance/bundle/resources/job_runs/interrupted_run/output.txt +++ b/acceptance/bundle/resources/job_runs/interrupted_run/output.txt @@ -43,11 +43,21 @@ Exit code: 1 "new_state": { "value": { "job_id": [MY_JOB_ID], + "lifecycle": { + "triggers": { + "on_file_change": {} + } + }, "result_state": "SUCCESS" } }, "remote_state": { "job_id": [MY_JOB_ID], + "lifecycle": { + "triggers": { + "on_file_change": {} + } + }, "run_id": [MY_RUN_ID], "run_name": "my-job", "run_page_url": "[DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID]?o=[NUMID]", 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 d9524633590..ec8b65a220b 100644 --- a/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt +++ b/acceptance/bundle/resources/job_runs/on_bundle_deploy/output.txt @@ -30,20 +30,6 @@ 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", @@ -110,14 +96,10 @@ Plan: 0 to add, 1 to change, 0 to delete, 1 unchanged >>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json { - "lifecycle": { + "lifecycle.triggers.on_bundle_deploy": { "action": "update", "reason": "trigger removed", - "old": { - "triggers": { - "on_bundle_deploy": "[UUID]" - } - } + "old": "[UUID]" }, "result_state": { "action": "skip", diff --git a/acceptance/bundle/resources/job_runs/on_file_change/output.txt b/acceptance/bundle/resources/job_runs/on_file_change/output.txt index d03307b90f2..9b662e1936d 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change/output.txt +++ b/acceptance/bundle/resources/job_runs/on_file_change/output.txt @@ -52,25 +52,17 @@ Plan: 1 to add, 0 to change, 1 to delete, 1 unchanged >>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json { - "lifecycle": { + "lifecycle.triggers.on_file_change.files": { "action": "recreate", "reason": "immutable", "old": { - "triggers": { - "on_file_change": { - "seed.txt": "[FILE_HASH][0]" - } - } + "seed.txt": "[FILE_HASH][0]" }, "new": { - "triggers": { - "on_file_change": { - "seed.txt": "[FILE_HASH][1]" - } - } + "seed.txt": "[FILE_HASH][1]" } }, - "lifecycle.triggers.on_file_change['seed.txt']": { + "lifecycle.triggers.on_file_change.files['seed.txt']": { "action": "recreate", "reason": "immutable", "old": "[FILE_HASH][0]", @@ -125,25 +117,17 @@ Warning: lifecycle.triggers.on_file_change: no files match "seed.txt" in databricks.yml:18:29 { - "lifecycle": { + "lifecycle.triggers.on_file_change.files": { "action": "recreate", "reason": "immutable", "old": { - "triggers": { - "on_file_change": { - "seed.txt": "[FILE_HASH][1]" - } - } + "seed.txt": "[FILE_HASH][1]" }, "new": { - "triggers": { - "on_file_change": { - "seed.txt": "" - } - } + "seed.txt": "" } }, - "lifecycle.triggers.on_file_change['seed.txt']": { + "lifecycle.triggers.on_file_change.files['seed.txt']": { "action": "recreate", "reason": "immutable", "old": "[FILE_HASH][1]", diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt b/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt index d424482b961..5b4ef23178c 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt @@ -25,29 +25,21 @@ Resources: 2 created, 0 changed, 0 deleted, 0 unchanged === editing one matched file re-fires >>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json { - "lifecycle": { + "lifecycle.triggers.on_file_change.files": { "action": "recreate", "reason": "immutable", "old": { - "triggers": { - "on_file_change": { - "migrations/001.txt": "[FILE_HASH][0]", - "migrations/002.txt": "[FILE_HASH][1]", - "mysubdir/a.txt": "[FILE_HASH][2]" - } - } + "migrations/001.txt": "[FILE_HASH][0]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" }, "new": { - "triggers": { - "on_file_change": { - "migrations/001.txt": "[FILE_HASH][3]", - "migrations/002.txt": "[FILE_HASH][1]", - "mysubdir/a.txt": "[FILE_HASH][2]" - } - } + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" } }, - "lifecycle.triggers.on_file_change['migrations/001.txt']": { + "lifecycle.triggers.on_file_change.files['migrations/001.txt']": { "action": "recreate", "reason": "immutable", "old": "[FILE_HASH][0]", @@ -91,29 +83,21 @@ Resources: 1 created, 0 changed, 1 deleted, 1 unchanged === editing a file in a subdirectory re-fires >>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json { - "lifecycle": { + "lifecycle.triggers.on_file_change.files": { "action": "recreate", "reason": "immutable", "old": { - "triggers": { - "on_file_change": { - "migrations/001.txt": "[FILE_HASH][3]", - "migrations/002.txt": "[FILE_HASH][1]", - "mysubdir/a.txt": "[FILE_HASH][2]" - } - } + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" }, "new": { - "triggers": { - "on_file_change": { - "migrations/001.txt": "[FILE_HASH][3]", - "migrations/002.txt": "[FILE_HASH][1]", - "mysubdir/a.txt": "[FILE_HASH][4]" - } - } + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][4]" } }, - "lifecycle.triggers.on_file_change['mysubdir/a.txt']": { + "lifecycle.triggers.on_file_change.files['mysubdir/a.txt']": { "action": "recreate", "reason": "immutable", "old": "[FILE_HASH][2]", @@ -130,30 +114,22 @@ Resources: 1 created, 0 changed, 1 deleted, 1 unchanged === a new matching file re-fires >>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json { - "lifecycle": { + "lifecycle.triggers.on_file_change.files": { "action": "recreate", "reason": "immutable", "old": { - "triggers": { - "on_file_change": { - "migrations/001.txt": "[FILE_HASH][3]", - "migrations/002.txt": "[FILE_HASH][1]", - "mysubdir/a.txt": "[FILE_HASH][2]" - } - } + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" }, "new": { - "triggers": { - "on_file_change": { - "migrations/001.txt": "[FILE_HASH][3]", - "migrations/002.txt": "[FILE_HASH][1]", - "migrations/003.txt": "[FILE_HASH][5]", - "mysubdir/a.txt": "[FILE_HASH][2]" - } - } + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "migrations/003.txt": "[FILE_HASH][5]", + "mysubdir/a.txt": "[FILE_HASH][2]" } }, - "lifecycle.triggers.on_file_change['migrations/003.txt']": { + "lifecycle.triggers.on_file_change.files['migrations/003.txt']": { "action": "recreate", "reason": "immutable", "new": "[FILE_HASH][5]" @@ -169,28 +145,20 @@ Resources: 1 created, 0 changed, 1 deleted, 1 unchanged === removing a matched file re-fires >>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json { - "lifecycle": { + "lifecycle.triggers.on_file_change.files": { "action": "recreate", "reason": "immutable", "old": { - "triggers": { - "on_file_change": { - "migrations/001.txt": "[FILE_HASH][3]", - "migrations/002.txt": "[FILE_HASH][1]", - "mysubdir/a.txt": "[FILE_HASH][2]" - } - } + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" }, "new": { - "triggers": { - "on_file_change": { - "migrations/001.txt": "[FILE_HASH][3]", - "mysubdir/a.txt": "[FILE_HASH][2]" - } - } + "migrations/001.txt": "[FILE_HASH][3]", + "mysubdir/a.txt": "[FILE_HASH][2]" } }, - "lifecycle.triggers.on_file_change['migrations/002.txt']": { + "lifecycle.triggers.on_file_change.files['migrations/002.txt']": { "action": "recreate", "reason": "immutable", "old": "[FILE_HASH][1]" @@ -206,26 +174,18 @@ Resources: 1 created, 0 changed, 1 deleted, 1 unchanged === editing a file below the glob does not re-fire >>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json { - "lifecycle": { + "lifecycle.triggers.on_file_change.files": { "action": "skip", - "reason": "missing_in_remote", + "reason": "not_returned_by_api", "old": { - "triggers": { - "on_file_change": { - "migrations/001.txt": "[FILE_HASH][3]", - "migrations/002.txt": "[FILE_HASH][1]", - "mysubdir/a.txt": "[FILE_HASH][2]" - } - } + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" }, "new": { - "triggers": { - "on_file_change": { - "migrations/001.txt": "[FILE_HASH][3]", - "migrations/002.txt": "[FILE_HASH][1]", - "mysubdir/a.txt": "[FILE_HASH][2]" - } - } + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" } }, "result_state": { @@ -243,26 +203,18 @@ Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged === ** is not recursive: it matches the same files as * >>> jq .plan["resources.job_runs.my_run"].changes tmp.plan.json { - "lifecycle": { + "lifecycle.triggers.on_file_change.files": { "action": "skip", - "reason": "missing_in_remote", + "reason": "not_returned_by_api", "old": { - "triggers": { - "on_file_change": { - "migrations/001.txt": "[FILE_HASH][3]", - "migrations/002.txt": "[FILE_HASH][1]", - "mysubdir/a.txt": "[FILE_HASH][2]" - } - } + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" }, "new": { - "triggers": { - "on_file_change": { - "migrations/001.txt": "[FILE_HASH][3]", - "migrations/002.txt": "[FILE_HASH][1]", - "mysubdir/a.txt": "[FILE_HASH][2]" - } - } + "migrations/001.txt": "[FILE_HASH][3]", + "migrations/002.txt": "[FILE_HASH][1]", + "mysubdir/a.txt": "[FILE_HASH][2]" } }, "result_state": { diff --git a/acceptance/bundle/resources/job_runs/redeploy/output.txt b/acceptance/bundle/resources/job_runs/redeploy/output.txt index 06129f79373..60111c98a93 100644 --- a/acceptance/bundle/resources/job_runs/redeploy/output.txt +++ b/acceptance/bundle/resources/job_runs/redeploy/output.txt @@ -59,6 +59,11 @@ Resources: "job_parameters": { "env": "prod" }, + "lifecycle": { + "triggers": { + "on_file_change": {} + } + }, "result_state": "SUCCESS" } }, @@ -67,6 +72,11 @@ Resources: "job_parameters": { "env": "dev" }, + "lifecycle": { + "triggers": { + "on_file_change": {} + } + }, "result_state": "SUCCESS", "run_id": [MY_RUN_ID], "run_name": "my-job", diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 63ed81cb78a..e27e89a7e3c 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -3,6 +3,7 @@ package dresources import ( "cmp" "context" + "encoding/json" "errors" "fmt" "slices" @@ -26,13 +27,24 @@ import ( // jobRunTimeout matches the timeout `bundle run` allows a run (bundle/run/job.go). const jobRunTimeout = 24 * time.Hour -// jobRunTriggerLocalPaths is shared by OverrideChangeDesc and DoUpdate so -// clearing a trigger stays a state-only update in both places. +// jobRunTriggerLocalPaths are state-only: DoUpdate must not wait, OverrideChangeDesc +// downgrades a cleared trigger to update. var jobRunTriggerLocalPaths = []string{ - "lifecycle", - "lifecycle.triggers", "lifecycle.triggers.on_bundle_deploy", - "lifecycle.triggers.on_file_change", + "lifecycle.triggers.on_file_change.files", +} + +func isJobRunTriggerPath(path string) bool { + return slices.Contains(jobRunTriggerLocalPaths, path) +} + +func hasJobRunNonTriggerChanges(changes Changes) bool { + for path, change := range changes { + if change.Action != deployplan.Skip && !isJobRunTriggerPath(path) { + return true + } + } + return false } // JobRunTriggersState is the persisted fingerprint of lifecycle.triggers. @@ -40,12 +52,44 @@ type JobRunTriggersState struct { // Fresh UUID each plan while armed so Old!=New forces recreate. OnBundleDeploy string `json:"on_bundle_deploy,omitempty"` // Path → content hash from ResolveJobRunFileTriggers; change to recreate. - OnFileChange map[string]string `json:"on_file_change,omitempty"` + OnFileChange *JobRunFileTriggerState `json:"on_file_change"` +} + +// JobRunFileTriggerState is always present so remote and state share one shape. +// Files is nil when the trigger is off, so dropping the trigger diffs here and +// a file appear/disappear diffs inside the map. +type JobRunFileTriggerState struct { + Files map[string]string `json:"files,omitempty"` +} + +// UnmarshalJSON accepts the wrapped map and the older path-to-hash map. +func (s *JobRunFileTriggerState) UnmarshalJSON(b []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if files, ok := raw["files"]; ok && len(files) > 0 && files[0] == '{' { + return json.Unmarshal(files, &s.Files) + } + if len(raw) == 0 { + s.Files = nil + return nil + } + return json.Unmarshal(b, &s.Files) } -// JobRunLifecycleState holds local-only lifecycle fields persisted in state. +// JobRunLifecycleState is the local-only trigger fingerprint, also present (empty) on remote. type JobRunLifecycleState struct { - Triggers *JobRunTriggersState `json:"triggers,omitempty"` + Triggers *JobRunTriggersState `json:"triggers"` +} + +func newJobRunLifecycleState() *JobRunLifecycleState { + return &JobRunLifecycleState{ + Triggers: &JobRunTriggersState{ + OnBundleDeploy: "", + OnFileChange: &JobRunFileTriggerState{Files: nil}, + }, + } } // JobRunState is the RunNow request plus the outcome required for planning. @@ -55,12 +99,13 @@ type JobRunState struct { // Always SUCCESS during planning and cleared before persistence. ResultState jobs.RunResultState `json:"result_state,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"` + // Local-only. Nested under lifecycle to mirror config and avoid colliding + // with a future Jobs API field. + Lifecycle *JobRunLifecycleState `json:"lifecycle"` } func (s *JobRunState) UnmarshalJSON(b []byte) error { + s.Lifecycle = newJobRunLifecycleState() return marshal.Unmarshal(b, s) } @@ -77,6 +122,9 @@ type JobRunRemote struct { // compare the two. See "RemapState is a dumb copy" in README.md. ResultState jobs.RunResultState `json:"result_state,omitempty"` + // Always the empty fingerprint: GetRun does not return triggers. + Lifecycle *JobRunLifecycleState `json:"lifecycle"` + RunId int64 `json:"run_id,omitempty"` RunName string `json:"run_name,omitempty"` State *jobs.RunState `json:"state,omitempty"` @@ -87,6 +135,7 @@ type JobRunRemote struct { // Custom marshaler needed because embedded RunNow's MarshalJSON would otherwise // take over and drop the additional fields. func (s *JobRunRemote) UnmarshalJSON(b []byte) error { + s.Lifecycle = newJobRunLifecycleState() return marshal.Unmarshal(b, s) } @@ -108,20 +157,13 @@ func (*ResourceJobRun) PrepareState(input *resources.JobRun) *JobRunState { state := &JobRunState{ RunNow: input.RunNow, ResultState: jobs.RunResultStateSuccess, - Lifecycle: nil, + Lifecycle: newJobRunLifecycleState(), } - if !input.HasOnBundleDeploy() && len(input.ResolvedFileTriggers) == 0 { - return state - } - onBundleDeploy := "" if input.HasOnBundleDeploy() { - onBundleDeploy = uuid.NewString() + state.Lifecycle.Triggers.OnBundleDeploy = uuid.NewString() } - state.Lifecycle = &JobRunLifecycleState{ - Triggers: &JobRunTriggersState{ - OnBundleDeploy: onBundleDeploy, - OnFileChange: input.ResolvedFileTriggers, - }, + if len(input.ResolvedFileTriggers) > 0 { + state.Lifecycle.Triggers.OnFileChange.Files = input.ResolvedFileTriggers } return state } @@ -162,6 +204,7 @@ func makeJobRunRemote(run *jobs.Run) *JobRunRemote { ForceSendFields: nil, }, ResultState: run.State.ResultState, + Lifecycle: newJobRunLifecycleState(), RunId: run.RunId, RunName: run.RunName, // Rebuilt, not copied: the SDK records explicitly-sent fields in @@ -202,8 +245,7 @@ func (*ResourceJobRun) RemapState(remote *JobRunRemote) *JobRunState { return &JobRunState{ RunNow: remote.RunNow, ResultState: remote.ResultState, - // Local-only lifecycle fingerprints stay unset on the remapped remote. - Lifecycle: nil, + Lifecycle: remote.Lifecycle, } } @@ -389,7 +431,7 @@ func reportRunLine(ctx context.Context, runID int64, msg string) { 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(jobRunTriggerLocalPaths...) { + if !hasJobRunNonTriggerChanges(entry.Changes) { config.ResultState = "" return nil, nil } @@ -405,10 +447,11 @@ func (r *ResourceJobRun) DoUpdate(ctx context.Context, id string, config *JobRun // 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 { - if slices.Contains(jobRunTriggerLocalPaths, path.String()) { - // 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 == "" { + pathString := path.String() + if isJobRunTriggerPath(pathString) { + removed := pathString == "lifecycle.triggers.on_bundle_deploy" && (change.New == nil || change.New == "") + removed = removed || pathString == "lifecycle.triggers.on_file_change.files" && change.New == nil + if removed { change.Action = deployplan.Update change.Reason = "trigger removed" } diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index 72de912e9d6..a42c661883b 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -351,6 +351,10 @@ func TestJobRunPrepareStateRequiresSuccess(t *testing.T) { state := (&ResourceJobRun{}).PrepareState(&resources.JobRun{RunNow: jobs.RunNow{JobId: 456}}) assert.Equal(t, jobs.RunResultStateSuccess, state.ResultState) + require.NotNil(t, state.Lifecycle) + require.NotNil(t, state.Lifecycle.Triggers) + assert.NotNil(t, state.Lifecycle.Triggers.OnFileChange) + assert.Nil(t, state.Lifecycle.Triggers.OnFileChange.Files) } func TestJobRunPrepareStateOnBundleDeploy(t *testing.T) { @@ -378,7 +382,7 @@ func TestJobRunPrepareStateOnFileChange(t *testing.T) { }) require.NotNil(t, state.Lifecycle) require.NotNil(t, state.Lifecycle.Triggers) - assert.Equal(t, hashes, state.Lifecycle.Triggers.OnFileChange) + assert.Equal(t, hashes, state.Lifecycle.Triggers.OnFileChange.Files) assert.Empty(t, state.Lifecycle.Triggers.OnBundleDeploy) }) @@ -393,24 +397,54 @@ func TestJobRunPrepareStateOnFileChange(t *testing.T) { require.NotNil(t, state.Lifecycle) require.NotNil(t, state.Lifecycle.Triggers) assert.NotEmpty(t, state.Lifecycle.Triggers.OnBundleDeploy) - assert.Equal(t, hashes, state.Lifecycle.Triggers.OnFileChange) + assert.Equal(t, hashes, state.Lifecycle.Triggers.OnFileChange.Files) }) } -func TestJobRunOverrideChangeDescTriggerRemoved(t *testing.T) { - r := &ResourceJobRun{} +func TestJobRunStateUnmarshalLifecycleCompatibility(t *testing.T) { + t.Run("missing lifecycle gets empty shape", func(t *testing.T) { + var state JobRunState + require.NoError(t, json.Unmarshal([]byte(`{}`), &state)) + require.NotNil(t, state.Lifecycle) + require.NotNil(t, state.Lifecycle.Triggers) + require.NotNil(t, state.Lifecycle.Triggers.OnFileChange) + assert.Nil(t, state.Lifecycle.Triggers.OnFileChange.Files) + }) - 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.Update, change.Action) - assert.Equal(t, "trigger removed", change.Reason) + t.Run("old path-to-hash map is preserved", func(t *testing.T) { + var state JobRunState + require.NoError(t, json.Unmarshal([]byte(`{ + "lifecycle": { + "triggers": { + "on_file_change": { + "a.txt": "abc" + } + } + } + }`), &state)) + assert.Equal(t, map[string]string{"a.txt": "abc"}, state.Lifecycle.Triggers.OnFileChange.Files) }) + t.Run("new wrapped map is preserved", func(t *testing.T) { + var state JobRunState + require.NoError(t, json.Unmarshal([]byte(`{ + "lifecycle": { + "triggers": { + "on_file_change": { + "files": { + "a.txt": "abc" + } + } + } + } + }`), &state)) + assert.Equal(t, map[string]string{"a.txt": "abc"}, state.Lifecycle.Triggers.OnFileChange.Files) + }) +} + +func TestJobRunOverrideChangeDescTriggerRemoved(t *testing.T) { + r := &ResourceJobRun{} + t.Run("clearing on_bundle_deploy leaf downgrades to update", func(t *testing.T) { change := &ChangeDesc{ Action: deployplan.Recreate, @@ -422,13 +456,13 @@ func TestJobRunOverrideChangeDescTriggerRemoved(t *testing.T) { assert.Equal(t, "trigger removed", change.Reason) }) - t.Run("clearing on_file_change leaf downgrades to update", func(t *testing.T) { + t.Run("clearing on_file_change files downgrades to update", func(t *testing.T) { change := &ChangeDesc{ Action: deployplan.Recreate, Old: map[string]string{"a.txt": "abc"}, New: nil, } - require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle.triggers.on_file_change"), change, nil)) + require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle.triggers.on_file_change.files"), change, nil)) assert.Equal(t, deployplan.Update, change.Action) assert.Equal(t, "trigger removed", change.Reason) }) @@ -446,10 +480,30 @@ func TestJobRunOverrideChangeDescTriggerRemoved(t *testing.T) { t.Run("changed on_file_change hash still recreates", func(t *testing.T) { change := &ChangeDesc{ Action: deployplan.Recreate, - Old: map[string]string{"a.txt": "old"}, - New: map[string]string{"a.txt": "new"}, + Old: "old", + New: "new", + } + require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle.triggers.on_file_change.files['a.txt']"), change, nil)) + assert.Equal(t, deployplan.Recreate, change.Action) + }) + + t.Run("removed matched file still recreates", func(t *testing.T) { + change := &ChangeDesc{ + Action: deployplan.Recreate, + Old: "old", + New: nil, + } + require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle.triggers.on_file_change.files['a.txt']"), change, nil)) + assert.Equal(t, deployplan.Recreate, change.Action) + }) + + t.Run("missing file fingerprint still recreates", func(t *testing.T) { + change := &ChangeDesc{ + Action: deployplan.Recreate, + Old: "old", + New: "", } - require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle.triggers.on_file_change"), change, nil)) + require.NoError(t, r.OverrideChangeDesc(t.Context(), structpath.MustParsePath("lifecycle.triggers.on_file_change.files['a.txt']"), change, nil)) assert.Equal(t, deployplan.Recreate, change.Action) }) } @@ -464,11 +518,13 @@ func TestJobRunRemapStateCarriesTheOutcome(t *testing.T) { "", } { t.Run(string(outcome), func(t *testing.T) { - remote := &JobRunRemote{RunId: 123, ResultState: outcome} + lifecycle := newJobRunLifecycleState() + remote := &JobRunRemote{RunId: 123, ResultState: outcome, Lifecycle: lifecycle} state := (&ResourceJobRun{}).RemapState(remote) assert.Equal(t, outcome, state.ResultState) + assert.Same(t, lifecycle, state.Lifecycle) }) } } diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index e98a1ac2330..06485a31054 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -175,6 +175,9 @@ resources: reason: not_returned_by_api - field: queue reason: not_returned_by_api + # Local-only trigger fingerprints; GetRun does not return them. + - field: lifecycle + reason: not_returned_by_api # A run is immutable and fire-once, so any change recreates it. Omitting # `field` matches every field (root; see TestFieldRuleOmittedIsRoot). # `field: ""` would instead match nothing. The one exception is a run that is diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index 2d5516d59c7..ca84850c33d 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -56,10 +56,6 @@ var knownMissingInRemoteType = map[string][]string{ "vector_search_endpoints": { "usage_policy_id", }, - "job_runs": { - // Local-only trigger fingerprints under lifecycle. - "lifecycle", - }, } // commonMissingInStateType lists fields that are commonly missing across all resource types. From 451d13c921a16c8f73f7e1752559f341cec44458 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Fri, 21 Aug 2026 13:02:42 +0000 Subject: [PATCH 16/16] job_runs: keep the lifecycle fingerprint out of the remote type Nest lifecycle by value so structdiff descends to the leaf that changed instead of reporting the whole subtree, and let RemapState supply the empty shape. GetRun never returns the fingerprints, so the remote type no longer advertises a lifecycle it cannot fill. --- acceptance/bundle/refschema/out.fields.txt | 12 ++--- .../resources/job_runs/failed_run/output.txt | 5 --- .../job_runs/interrupted_run/output.txt | 5 --- .../job_runs/on_file_change_glob/output.txt | 4 +- .../resources/job_runs/redeploy/output.txt | 5 --- bundle/direct/dresources/job_run.go | 45 +++++++++---------- bundle/direct/dresources/job_run_test.go | 17 +------ bundle/direct/dresources/resources.yml | 3 -- bundle/direct/dresources/type_test.go | 5 +++ 9 files changed, 37 insertions(+), 64 deletions(-) diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index d6da3840c2a..691c5589bcc 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -845,16 +845,16 @@ 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 REMOTE STATE resources.job_runs.*.lifecycle *resources.JobRunLifecycle INPUT +resources.job_runs.*.lifecycle dresources.JobRunLifecycleState STATE resources.job_runs.*.lifecycle resources.Lifecycle INPUT resources.job_runs.*.lifecycle.prevent_destroy bool INPUT -resources.job_runs.*.lifecycle.triggers *dresources.JobRunTriggersState REMOTE STATE resources.job_runs.*.lifecycle.triggers []resources.JobRunTrigger INPUT -resources.job_runs.*.lifecycle.triggers.on_bundle_deploy string REMOTE STATE -resources.job_runs.*.lifecycle.triggers.on_file_change *dresources.JobRunFileTriggerState REMOTE STATE -resources.job_runs.*.lifecycle.triggers.on_file_change.files map[string]string REMOTE STATE -resources.job_runs.*.lifecycle.triggers.on_file_change.files.* string REMOTE STATE +resources.job_runs.*.lifecycle.triggers dresources.JobRunTriggersState STATE +resources.job_runs.*.lifecycle.triggers.on_bundle_deploy string STATE +resources.job_runs.*.lifecycle.triggers.on_file_change dresources.JobRunFileTriggerState STATE +resources.job_runs.*.lifecycle.triggers.on_file_change.files map[string]string STATE +resources.job_runs.*.lifecycle.triggers.on_file_change.files.* string STATE resources.job_runs.*.lifecycle.triggers[*] resources.JobRunTrigger INPUT resources.job_runs.*.lifecycle.triggers[*].on_bundle_deploy *bool INPUT resources.job_runs.*.lifecycle.triggers[*].on_file_change *string INPUT diff --git a/acceptance/bundle/resources/job_runs/failed_run/output.txt b/acceptance/bundle/resources/job_runs/failed_run/output.txt index 0c6bddb0d9b..37f1a831681 100644 --- a/acceptance/bundle/resources/job_runs/failed_run/output.txt +++ b/acceptance/bundle/resources/job_runs/failed_run/output.txt @@ -37,11 +37,6 @@ Files: 7 uploaded, 0 deleted }, "remote_state": { "job_id": [MY_JOB_ID], - "lifecycle": { - "triggers": { - "on_file_change": {} - } - }, "result_state": "FAILED", "run_id": [MY_RUN_ID], "run_name": "test-job-[UNIQUE_NAME]", diff --git a/acceptance/bundle/resources/job_runs/interrupted_run/output.txt b/acceptance/bundle/resources/job_runs/interrupted_run/output.txt index 47dfb8c7617..df52920c879 100644 --- a/acceptance/bundle/resources/job_runs/interrupted_run/output.txt +++ b/acceptance/bundle/resources/job_runs/interrupted_run/output.txt @@ -53,11 +53,6 @@ Exit code: 1 }, "remote_state": { "job_id": [MY_JOB_ID], - "lifecycle": { - "triggers": { - "on_file_change": {} - } - }, "run_id": [MY_RUN_ID], "run_name": "my-job", "run_page_url": "[DATABRICKS_URL]/jobs/[MY_JOB_ID]/runs/[MY_RUN_ID]?o=[NUMID]", diff --git a/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt b/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt index 5b4ef23178c..69a7e5ece94 100644 --- a/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt +++ b/acceptance/bundle/resources/job_runs/on_file_change_glob/output.txt @@ -176,7 +176,7 @@ Resources: 1 created, 0 changed, 1 deleted, 1 unchanged { "lifecycle.triggers.on_file_change.files": { "action": "skip", - "reason": "not_returned_by_api", + "reason": "missing_in_remote", "old": { "migrations/001.txt": "[FILE_HASH][3]", "migrations/002.txt": "[FILE_HASH][1]", @@ -205,7 +205,7 @@ Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged { "lifecycle.triggers.on_file_change.files": { "action": "skip", - "reason": "not_returned_by_api", + "reason": "missing_in_remote", "old": { "migrations/001.txt": "[FILE_HASH][3]", "migrations/002.txt": "[FILE_HASH][1]", diff --git a/acceptance/bundle/resources/job_runs/redeploy/output.txt b/acceptance/bundle/resources/job_runs/redeploy/output.txt index 60111c98a93..a80685733ad 100644 --- a/acceptance/bundle/resources/job_runs/redeploy/output.txt +++ b/acceptance/bundle/resources/job_runs/redeploy/output.txt @@ -72,11 +72,6 @@ Resources: "job_parameters": { "env": "dev" }, - "lifecycle": { - "triggers": { - "on_file_change": {} - } - }, "result_state": "SUCCESS", "run_id": [MY_RUN_ID], "run_name": "my-job", diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index e27e89a7e3c..a300318dabe 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -51,13 +51,12 @@ func hasJobRunNonTriggerChanges(changes Changes) bool { type JobRunTriggersState struct { // Fresh UUID each plan while armed so Old!=New forces recreate. OnBundleDeploy string `json:"on_bundle_deploy,omitempty"` - // Path → content hash from ResolveJobRunFileTriggers; change to recreate. - OnFileChange *JobRunFileTriggerState `json:"on_file_change"` + // Content hashes from ResolveJobRunFileTriggers; any change recreates. + OnFileChange JobRunFileTriggerState `json:"on_file_change"` } -// JobRunFileTriggerState is always present so remote and state share one shape. -// Files is nil when the trigger is off, so dropping the trigger diffs here and -// a file appear/disappear diffs inside the map. +// JobRunFileTriggerState wraps the hashes so turning the trigger off (Files nil) +// diffs at one path, distinct from the per-file entries a changed file produces. type JobRunFileTriggerState struct { Files map[string]string `json:"files,omitempty"` } @@ -78,16 +77,21 @@ func (s *JobRunFileTriggerState) UnmarshalJSON(b []byte) error { return json.Unmarshal(b, &s.Files) } -// JobRunLifecycleState is the local-only trigger fingerprint, also present (empty) on remote. +// JobRunLifecycleState is the local-only trigger fingerprint. Nested by value, +// not by pointer: structdiff cannot descend into a nil pointer and would report +// the whole subtree at "lifecycle" instead of the leaf that actually changed. type JobRunLifecycleState struct { - Triggers *JobRunTriggersState `json:"triggers"` + Triggers JobRunTriggersState `json:"triggers"` } -func newJobRunLifecycleState() *JobRunLifecycleState { - return &JobRunLifecycleState{ - Triggers: &JobRunTriggersState{ +// Zero value spelled out field by field, as exhaustruct requires. +func emptyJobRunLifecycleState() JobRunLifecycleState { + return JobRunLifecycleState{ + Triggers: JobRunTriggersState{ OnBundleDeploy: "", - OnFileChange: &JobRunFileTriggerState{Files: nil}, + OnFileChange: JobRunFileTriggerState{ + Files: nil, + }, }, } } @@ -101,11 +105,10 @@ type JobRunState struct { // Local-only. Nested under lifecycle to mirror config and avoid colliding // with a future Jobs API field. - Lifecycle *JobRunLifecycleState `json:"lifecycle"` + Lifecycle JobRunLifecycleState `json:"lifecycle"` } func (s *JobRunState) UnmarshalJSON(b []byte) error { - s.Lifecycle = newJobRunLifecycleState() return marshal.Unmarshal(b, s) } @@ -113,8 +116,8 @@ func (s JobRunState) MarshalJSON() ([]byte, error) { return marshal.Marshal(s) } -// JobRunRemote embeds RunNow so every StateType path is a valid RemoteType path -// (see TestRemoteSuperset), plus the run's output-only fields for a faithful view. +// JobRunRemote is the RunNow request plus the run's output-only fields. It has no +// lifecycle: GetRun never returns the fingerprints (see knownMissingInRemoteType). type JobRunRemote struct { jobs.RunNow @@ -122,9 +125,6 @@ type JobRunRemote struct { // compare the two. See "RemapState is a dumb copy" in README.md. ResultState jobs.RunResultState `json:"result_state,omitempty"` - // Always the empty fingerprint: GetRun does not return triggers. - Lifecycle *JobRunLifecycleState `json:"lifecycle"` - RunId int64 `json:"run_id,omitempty"` RunName string `json:"run_name,omitempty"` State *jobs.RunState `json:"state,omitempty"` @@ -135,7 +135,6 @@ type JobRunRemote struct { // Custom marshaler needed because embedded RunNow's MarshalJSON would otherwise // take over and drop the additional fields. func (s *JobRunRemote) UnmarshalJSON(b []byte) error { - s.Lifecycle = newJobRunLifecycleState() return marshal.Unmarshal(b, s) } @@ -157,7 +156,7 @@ func (*ResourceJobRun) PrepareState(input *resources.JobRun) *JobRunState { state := &JobRunState{ RunNow: input.RunNow, ResultState: jobs.RunResultStateSuccess, - Lifecycle: newJobRunLifecycleState(), + Lifecycle: emptyJobRunLifecycleState(), } if input.HasOnBundleDeploy() { state.Lifecycle.Triggers.OnBundleDeploy = uuid.NewString() @@ -204,7 +203,6 @@ func makeJobRunRemote(run *jobs.Run) *JobRunRemote { ForceSendFields: nil, }, ResultState: run.State.ResultState, - Lifecycle: newJobRunLifecycleState(), RunId: run.RunId, RunName: run.RunName, // Rebuilt, not copied: the SDK records explicitly-sent fields in @@ -240,12 +238,13 @@ 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. +// outcome the run reached. Lifecycle has no remote counterpart, so it stays empty +// and the planner skips it as missing_in_remote. func (*ResourceJobRun) RemapState(remote *JobRunRemote) *JobRunState { return &JobRunState{ RunNow: remote.RunNow, ResultState: remote.ResultState, - Lifecycle: remote.Lifecycle, + Lifecycle: emptyJobRunLifecycleState(), } } diff --git a/bundle/direct/dresources/job_run_test.go b/bundle/direct/dresources/job_run_test.go index a42c661883b..78ec09d3604 100644 --- a/bundle/direct/dresources/job_run_test.go +++ b/bundle/direct/dresources/job_run_test.go @@ -351,9 +351,6 @@ func TestJobRunPrepareStateRequiresSuccess(t *testing.T) { state := (&ResourceJobRun{}).PrepareState(&resources.JobRun{RunNow: jobs.RunNow{JobId: 456}}) assert.Equal(t, jobs.RunResultStateSuccess, state.ResultState) - require.NotNil(t, state.Lifecycle) - require.NotNil(t, state.Lifecycle.Triggers) - assert.NotNil(t, state.Lifecycle.Triggers.OnFileChange) assert.Nil(t, state.Lifecycle.Triggers.OnFileChange.Files) } @@ -365,8 +362,6 @@ func TestJobRunPrepareStateOnBundleDeploy(t *testing.T) { }, } first := (&ResourceJobRun{}).PrepareState(input) - require.NotNil(t, first.Lifecycle) - require.NotNil(t, first.Lifecycle.Triggers) assert.NotEmpty(t, first.Lifecycle.Triggers.OnBundleDeploy) second := (&ResourceJobRun{}).PrepareState(input) @@ -380,8 +375,6 @@ func TestJobRunPrepareStateOnFileChange(t *testing.T) { state := (&ResourceJobRun{}).PrepareState(&resources.JobRun{ ResolvedFileTriggers: hashes, }) - require.NotNil(t, state.Lifecycle) - require.NotNil(t, state.Lifecycle.Triggers) assert.Equal(t, hashes, state.Lifecycle.Triggers.OnFileChange.Files) assert.Empty(t, state.Lifecycle.Triggers.OnBundleDeploy) }) @@ -394,8 +387,6 @@ func TestJobRunPrepareStateOnFileChange(t *testing.T) { }, ResolvedFileTriggers: hashes, }) - require.NotNil(t, state.Lifecycle) - require.NotNil(t, state.Lifecycle.Triggers) assert.NotEmpty(t, state.Lifecycle.Triggers.OnBundleDeploy) assert.Equal(t, hashes, state.Lifecycle.Triggers.OnFileChange.Files) }) @@ -405,9 +396,6 @@ func TestJobRunStateUnmarshalLifecycleCompatibility(t *testing.T) { t.Run("missing lifecycle gets empty shape", func(t *testing.T) { var state JobRunState require.NoError(t, json.Unmarshal([]byte(`{}`), &state)) - require.NotNil(t, state.Lifecycle) - require.NotNil(t, state.Lifecycle.Triggers) - require.NotNil(t, state.Lifecycle.Triggers.OnFileChange) assert.Nil(t, state.Lifecycle.Triggers.OnFileChange.Files) }) @@ -518,13 +506,12 @@ func TestJobRunRemapStateCarriesTheOutcome(t *testing.T) { "", } { t.Run(string(outcome), func(t *testing.T) { - lifecycle := newJobRunLifecycleState() - remote := &JobRunRemote{RunId: 123, ResultState: outcome, Lifecycle: lifecycle} + remote := &JobRunRemote{RunId: 123, ResultState: outcome} state := (&ResourceJobRun{}).RemapState(remote) assert.Equal(t, outcome, state.ResultState) - assert.Same(t, lifecycle, state.Lifecycle) + assert.Equal(t, emptyJobRunLifecycleState(), state.Lifecycle) }) } } diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 06485a31054..e98a1ac2330 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -175,9 +175,6 @@ resources: reason: not_returned_by_api - field: queue reason: not_returned_by_api - # Local-only trigger fingerprints; GetRun does not return them. - - field: lifecycle - reason: not_returned_by_api # A run is immutable and fire-once, so any change recreates it. Omitting # `field` matches every field (root; see TestFieldRuleOmittedIsRoot). # `field: ""` would instead match nothing. The one exception is a run that is diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index ca84850c33d..7ad9872c373 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -19,6 +19,11 @@ var knownMissingInRemoteType = map[string][]string{ "external_locations": { "skip_validation", }, + "job_runs": { + // Deliberate omission: the trigger fingerprints are client-side only, so + // GetRun has nothing to report them under. + "lifecycle", + }, "model_serving_endpoints": { "rate_limits", },