From be0eca098ce840163341b3d3a832d64e4af13b68 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Tue, 18 Aug 2026 12:25:26 +0000 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 }