From a9a9561dceeefcb6a176e4ffea4f8e2f3980da84 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Thu, 16 Jul 2026 09:44:48 +0000 Subject: [PATCH 1/3] Skip app config/git_source drift when the app has no deployment The app deploy-only fields (config, git_source) are applied through an app deployment, which only happens once the app compute is started (manageLifecycle). Until then DoRead leaves them nil, so an app that set config or git_source without lifecycle.started=true planned a perpetual update for a field that was never deployed. Extend OverrideChangeDesc to skip config and git_source while there is no active deployment, keying off remote.ActiveDeployment. source_code_path was already skipped; matching on the top-level field (Prefix(1)) now also catches nested config diffs. Real out-of-band drift is still reported once a deployment exists. Adds acceptance tests for the config and git_source cases. --- .../apps/config-no-deployment/app/app.py | 10 ++++ .../apps/config-no-deployment/app/app.yaml | 3 ++ .../apps/config-no-deployment/databricks.yml | 14 ++++++ .../apps/config-no-deployment/out.test.toml | 5 ++ .../apps/config-no-deployment/output.txt | 50 +++++++++++++++++++ .../apps/config-no-deployment/script | 10 ++++ .../apps/config-no-deployment/test.toml | 11 ++++ .../git-source-no-deployment/databricks.yml | 10 ++++ .../git-source-no-deployment/out.test.toml | 5 ++ .../apps/git-source-no-deployment/output.txt | 26 ++++++++++ .../apps/git-source-no-deployment/script | 10 ++++ .../apps/git-source-no-deployment/test.toml | 11 ++++ bundle/direct/dresources/app.go | 21 ++++++-- 13 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 acceptance/bundle/resources/apps/config-no-deployment/app/app.py create mode 100644 acceptance/bundle/resources/apps/config-no-deployment/app/app.yaml create mode 100644 acceptance/bundle/resources/apps/config-no-deployment/databricks.yml create mode 100644 acceptance/bundle/resources/apps/config-no-deployment/out.test.toml create mode 100644 acceptance/bundle/resources/apps/config-no-deployment/output.txt create mode 100644 acceptance/bundle/resources/apps/config-no-deployment/script create mode 100644 acceptance/bundle/resources/apps/config-no-deployment/test.toml create mode 100644 acceptance/bundle/resources/apps/git-source-no-deployment/databricks.yml create mode 100644 acceptance/bundle/resources/apps/git-source-no-deployment/out.test.toml create mode 100644 acceptance/bundle/resources/apps/git-source-no-deployment/output.txt create mode 100644 acceptance/bundle/resources/apps/git-source-no-deployment/script create mode 100644 acceptance/bundle/resources/apps/git-source-no-deployment/test.toml diff --git a/acceptance/bundle/resources/apps/config-no-deployment/app/app.py b/acceptance/bundle/resources/apps/config-no-deployment/app/app.py new file mode 100644 index 00000000000..96fa00a4f14 --- /dev/null +++ b/acceptance/bundle/resources/apps/config-no-deployment/app/app.py @@ -0,0 +1,10 @@ +import os + +from flask import Flask + +app = Flask(__name__) + + +@app.route("/") +def home(): + return "Hello from config-no-deployment app!" diff --git a/acceptance/bundle/resources/apps/config-no-deployment/app/app.yaml b/acceptance/bundle/resources/apps/config-no-deployment/app/app.yaml new file mode 100644 index 00000000000..61471358dce --- /dev/null +++ b/acceptance/bundle/resources/apps/config-no-deployment/app/app.yaml @@ -0,0 +1,3 @@ +command: + - python + - app.py diff --git a/acceptance/bundle/resources/apps/config-no-deployment/databricks.yml b/acceptance/bundle/resources/apps/config-no-deployment/databricks.yml new file mode 100644 index 00000000000..c730a8d1dd5 --- /dev/null +++ b/acceptance/bundle/resources/apps/config-no-deployment/databricks.yml @@ -0,0 +1,14 @@ +bundle: + name: app-config-no-deployment + +resources: + apps: + myapp: + name: test-app-config-no-deployment + description: my_app_description + source_code_path: ./app + config: + command: ["python", "app.py"] + env: + - name: MY_ENV_VAR + value: test_value diff --git a/acceptance/bundle/resources/apps/config-no-deployment/out.test.toml b/acceptance/bundle/resources/apps/config-no-deployment/out.test.toml new file mode 100644 index 00000000000..1a3e24fa574 --- /dev/null +++ b/acceptance/bundle/resources/apps/config-no-deployment/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false +GOOSOnPR.darwin = false +GOOSOnPR.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/apps/config-no-deployment/output.txt b/acceptance/bundle/resources/apps/config-no-deployment/output.txt new file mode 100644 index 00000000000..c2b28859491 --- /dev/null +++ b/acceptance/bundle/resources/apps/config-no-deployment/output.txt @@ -0,0 +1,50 @@ + +=== Deploy: app created with no_compute; config is not deployed until the app starts +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/app-config-no-deployment/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== Plan is a no-op: config/source_code_path drift is skipped while the app has no deployment +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged + +=== Both deploy-only fields are skipped with reason "no deployment" +>>> [CLI] bundle plan -o json +{ + "config": { + "action": "skip", + "reason": "no deployment", + "old": { + "command": [ + "python", + "app.py" + ], + "env": [ + { + "name": "MY_ENV_VAR", + "value": "test_value" + } + ] + }, + "new": { + "command": [ + "python", + "app.py" + ], + "env": [ + { + "name": "MY_ENV_VAR", + "value": "test_value" + } + ] + } + }, + "source_code_path": { + "action": "skip", + "reason": "no deployment", + "old": "/Workspace/Users/[USERNAME]/.bundle/app-config-no-deployment/default/files/app", + "new": "/Workspace/Users/[USERNAME]/.bundle/app-config-no-deployment/default/files/app" + } +} diff --git a/acceptance/bundle/resources/apps/config-no-deployment/script b/acceptance/bundle/resources/apps/config-no-deployment/script new file mode 100644 index 00000000000..2e66002df66 --- /dev/null +++ b/acceptance/bundle/resources/apps/config-no-deployment/script @@ -0,0 +1,10 @@ +echo "*" > .gitignore + +title "Deploy: app created with no_compute; config is not deployed until the app starts" +trace $CLI bundle deploy + +title "Plan is a no-op: config/source_code_path drift is skipped while the app has no deployment" +trace $CLI bundle plan | contains.py "Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged" + +title "Both deploy-only fields are skipped with reason \"no deployment\"" +trace $CLI bundle plan -o json | jq '.plan[].changes | {config, source_code_path}' diff --git a/acceptance/bundle/resources/apps/config-no-deployment/test.toml b/acceptance/bundle/resources/apps/config-no-deployment/test.toml new file mode 100644 index 00000000000..6d58b36d864 --- /dev/null +++ b/acceptance/bundle/resources/apps/config-no-deployment/test.toml @@ -0,0 +1,11 @@ +Local = true +Cloud = false + +# Asserts plan classification, not requests; drop the inherited RecordRequests. +RecordRequests = false + +Ignore = [".databricks"] + +# Direct engine only: the skip logic lives in OverrideChangeDesc. Mirrors +# lifecycle-started-omitted. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/apps/git-source-no-deployment/databricks.yml b/acceptance/bundle/resources/apps/git-source-no-deployment/databricks.yml new file mode 100644 index 00000000000..d3711e17833 --- /dev/null +++ b/acceptance/bundle/resources/apps/git-source-no-deployment/databricks.yml @@ -0,0 +1,10 @@ +bundle: + name: app-git-source-no-deployment + +resources: + apps: + myapp: + name: test-app-git-source-no-deployment + description: my_app_description + git_source: + branch: main diff --git a/acceptance/bundle/resources/apps/git-source-no-deployment/out.test.toml b/acceptance/bundle/resources/apps/git-source-no-deployment/out.test.toml new file mode 100644 index 00000000000..1a3e24fa574 --- /dev/null +++ b/acceptance/bundle/resources/apps/git-source-no-deployment/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false +GOOSOnPR.darwin = false +GOOSOnPR.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/apps/git-source-no-deployment/output.txt b/acceptance/bundle/resources/apps/git-source-no-deployment/output.txt new file mode 100644 index 00000000000..6a75163aa5e --- /dev/null +++ b/acceptance/bundle/resources/apps/git-source-no-deployment/output.txt @@ -0,0 +1,26 @@ + +=== Deploy: app created with no_compute; git_source is not deployed until the app starts +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/app-git-source-no-deployment/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== Plan is a no-op: git_source drift is skipped while the app has no deployment +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged + +=== git_source is skipped with reason "no deployment" +>>> [CLI] bundle plan -o json +{ + "git_source": { + "action": "skip", + "reason": "no deployment", + "old": { + "branch": "main" + }, + "new": { + "branch": "main" + } + } +} diff --git a/acceptance/bundle/resources/apps/git-source-no-deployment/script b/acceptance/bundle/resources/apps/git-source-no-deployment/script new file mode 100644 index 00000000000..d79e3cbef3a --- /dev/null +++ b/acceptance/bundle/resources/apps/git-source-no-deployment/script @@ -0,0 +1,10 @@ +echo "*" > .gitignore + +title "Deploy: app created with no_compute; git_source is not deployed until the app starts" +trace $CLI bundle deploy + +title "Plan is a no-op: git_source drift is skipped while the app has no deployment" +trace $CLI bundle plan | contains.py "Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged" + +title "git_source is skipped with reason \"no deployment\"" +trace $CLI bundle plan -o json | jq '.plan[].changes | {git_source}' diff --git a/acceptance/bundle/resources/apps/git-source-no-deployment/test.toml b/acceptance/bundle/resources/apps/git-source-no-deployment/test.toml new file mode 100644 index 00000000000..b943555ebc1 --- /dev/null +++ b/acceptance/bundle/resources/apps/git-source-no-deployment/test.toml @@ -0,0 +1,11 @@ +Local = true +Cloud = false + +# Asserts plan classification, not requests; drop the inherited RecordRequests. +RecordRequests = false + +Ignore = [".databricks"] + +# Direct engine only: the skip logic lives in OverrideChangeDesc. Mirrors +# config-no-deployment. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/bundle/direct/dresources/app.go b/bundle/direct/dresources/app.go index e629027b24b..05b75005850 100644 --- a/bundle/direct/dresources/app.go +++ b/bundle/direct/dresources/app.go @@ -246,12 +246,23 @@ func hasAppChanges(entry *PlanEntry) bool { return entry.Changes.HasChangeExcept("source_code_path", "config", "git_source", "lifecycle", "lifecycle.started") } -// OverrideChangeDesc skips source_code_path drift when the remote value is empty. -// This happens when an app has no deployment yet (DefaultSourceCodePath is unset). +// OverrideChangeDesc skips drift on deploy-only fields (source_code_path, config, +// git_source) until the app has a deployment: they're applied via an app deployment +// (see manageLifecycle), so DoRead leaves them nil until then and any diff is +// spurious. Real out-of-band drift is reported again once a deployment exists. func (*ResourceApp) OverrideChangeDesc(_ context.Context, path *structpath.PathNode, change *ChangeDesc, remote *AppRemote) error { - if path.String() == "source_code_path" && (remote.SourceCodePath == "" || remote.SourceCodePath == "null") { - change.Action = deployplan.Skip - change.Reason = "no deployment" + // Prefix(1) so a nested diff (e.g. config.command) matches its top-level field. + switch path.Prefix(1).String() { + case "source_code_path": + if remote.SourceCodePath == "" || remote.SourceCodePath == "null" { + change.Action = deployplan.Skip + change.Reason = "no deployment" + } + case "config", "git_source": + if remote.ActiveDeployment == nil { + change.Action = deployplan.Skip + change.Reason = "no deployment" + } } return nil } From 46fa9b59f4a02bac89c9d476e886965bb204d5b5 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Fri, 17 Jul 2026 09:29:34 +0000 Subject: [PATCH 2/3] Treat stopped apps as having no active deployment for drift Unify the deploy-only drift skip (source_code_path, config, git_source) on ActiveDeployment == nil, which the backend also returns for a stopped app, not just before the first deploy. Update the reason to "no active deployment" and fix the test server's stop handler to clear the active deployment so acceptance tests match cloud behavior. Add config-drift-stopped covering the stopped case. --- .../apps/config-drift-stopped/app/app.py | 5 ++ .../config-drift-stopped/databricks.yml.tmpl | 18 +++++ .../apps/config-drift-stopped/out.test.toml | 5 ++ .../apps/config-drift-stopped/output.txt | 73 +++++++++++++++++++ .../apps/config-drift-stopped/script | 19 +++++ .../apps/config-drift-stopped/test.toml | 11 +++ .../apps/config-no-deployment/output.txt | 8 +- .../apps/config-no-deployment/script | 4 +- .../apps/git-source-no-deployment/output.txt | 6 +- .../apps/git-source-no-deployment/script | 4 +- bundle/direct/dresources/app.go | 20 +++-- libs/testserver/apps.go | 5 ++ 12 files changed, 156 insertions(+), 22 deletions(-) create mode 100644 acceptance/bundle/resources/apps/config-drift-stopped/app/app.py create mode 100644 acceptance/bundle/resources/apps/config-drift-stopped/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/apps/config-drift-stopped/out.test.toml create mode 100644 acceptance/bundle/resources/apps/config-drift-stopped/output.txt create mode 100644 acceptance/bundle/resources/apps/config-drift-stopped/script create mode 100644 acceptance/bundle/resources/apps/config-drift-stopped/test.toml diff --git a/acceptance/bundle/resources/apps/config-drift-stopped/app/app.py b/acceptance/bundle/resources/apps/config-drift-stopped/app/app.py new file mode 100644 index 00000000000..ad1e64f9fb0 --- /dev/null +++ b/acceptance/bundle/resources/apps/config-drift-stopped/app/app.py @@ -0,0 +1,5 @@ +import http.server, os + +http.server.HTTPServer( + ("", int(os.environ["DATABRICKS_APP_PORT"])), http.server.SimpleHTTPRequestHandler +).serve_forever() diff --git a/acceptance/bundle/resources/apps/config-drift-stopped/databricks.yml.tmpl b/acceptance/bundle/resources/apps/config-drift-stopped/databricks.yml.tmpl new file mode 100644 index 00000000000..d6e5da23106 --- /dev/null +++ b/acceptance/bundle/resources/apps/config-drift-stopped/databricks.yml.tmpl @@ -0,0 +1,18 @@ +bundle: + name: config-drift-stopped-$UNIQUE_NAME + +resources: + apps: + myapp: + name: $UNIQUE_NAME + description: my_app + source_code_path: ./app + config: + command: + - python + - app.py + env: + - name: MY_VAR + value: original_value + lifecycle: + started: true diff --git a/acceptance/bundle/resources/apps/config-drift-stopped/out.test.toml b/acceptance/bundle/resources/apps/config-drift-stopped/out.test.toml new file mode 100644 index 00000000000..1a3e24fa574 --- /dev/null +++ b/acceptance/bundle/resources/apps/config-drift-stopped/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false +GOOSOnPR.darwin = false +GOOSOnPR.windows = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/apps/config-drift-stopped/output.txt b/acceptance/bundle/resources/apps/config-drift-stopped/output.txt new file mode 100644 index 00000000000..f26277b5804 --- /dev/null +++ b/acceptance/bundle/resources/apps/config-drift-stopped/output.txt @@ -0,0 +1,73 @@ + +=== Deploy with started=true so the app has an active deployment +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/config-drift-stopped-[UNIQUE_NAME]/default/files... +Deploying resources... +✓ Deployment succeeded +Updating deployment state... +Deployment complete! + +=== Change config while running: drift is detected (active deployment present) +>>> update_file.py databricks.yml original_value changed_value + +>>> [CLI] bundle plan -o json +{ + "config.env[0].value": { + "action": "update", + "old": "original_value", + "new": "changed_value", + "remote": "original_value" + } +} + +=== Stop the app: the backend clears the active deployment +>>> [CLI] apps stop [UNIQUE_NAME] +"STOPPED" + +=== Same config change is now skipped: no active deployment to compare against +>>> [CLI] bundle plan -o json +{ + "config": { + "action": "skip", + "reason": "no active deployment", + "old": { + "command": [ + "python", + "app.py" + ], + "env": [ + { + "name": "MY_VAR", + "value": "original_value" + } + ] + }, + "new": { + "command": [ + "python", + "app.py" + ], + "env": [ + { + "name": "MY_VAR", + "value": "changed_value" + } + ] + } + }, + "config.env[0].value": { + "action": "skip", + "reason": "no active deployment", + "old": "original_value", + "new": "changed_value" + } +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.apps.myapp + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/config-drift-stopped-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/apps/config-drift-stopped/script b/acceptance/bundle/resources/apps/config-drift-stopped/script new file mode 100644 index 00000000000..c449a3099a3 --- /dev/null +++ b/acceptance/bundle/resources/apps/config-drift-stopped/script @@ -0,0 +1,19 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +title "Deploy with started=true so the app has an active deployment" +trace $CLI bundle deploy + +title "Change config while running: drift is detected (active deployment present)" +trace update_file.py databricks.yml original_value changed_value +trace $CLI bundle plan -o json | jq '.plan[].changes | with_entries(select(.key | startswith("config")))' + +title "Stop the app: the backend clears the active deployment" +trace $CLI apps stop $UNIQUE_NAME | jq '.compute_status.state' + +title "Same config change is now skipped: no active deployment to compare against" +trace $CLI bundle plan -o json | jq '.plan[].changes | with_entries(select(.key | startswith("config")))' diff --git a/acceptance/bundle/resources/apps/config-drift-stopped/test.toml b/acceptance/bundle/resources/apps/config-drift-stopped/test.toml new file mode 100644 index 00000000000..5b3deb5b630 --- /dev/null +++ b/acceptance/bundle/resources/apps/config-drift-stopped/test.toml @@ -0,0 +1,11 @@ +Local = true +Cloud = false + +# Asserts plan classification, not requests; drop the inherited RecordRequests. +RecordRequests = false + +Ignore = [".databricks", "databricks.yml"] + +# Direct engine only: the skip logic lives in OverrideChangeDesc. Mirrors +# config-no-deployment. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/apps/config-no-deployment/output.txt b/acceptance/bundle/resources/apps/config-no-deployment/output.txt index c2b28859491..9b158ea51fd 100644 --- a/acceptance/bundle/resources/apps/config-no-deployment/output.txt +++ b/acceptance/bundle/resources/apps/config-no-deployment/output.txt @@ -6,16 +6,16 @@ Deploying resources... Updating deployment state... Deployment complete! -=== Plan is a no-op: config/source_code_path drift is skipped while the app has no deployment +=== Plan is a no-op: config/source_code_path drift is skipped while the app has no active deployment >>> [CLI] bundle plan Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged -=== Both deploy-only fields are skipped with reason "no deployment" +=== Both deploy-only fields are skipped with reason "no active deployment" >>> [CLI] bundle plan -o json { "config": { "action": "skip", - "reason": "no deployment", + "reason": "no active deployment", "old": { "command": [ "python", @@ -43,7 +43,7 @@ Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged }, "source_code_path": { "action": "skip", - "reason": "no deployment", + "reason": "no active deployment", "old": "/Workspace/Users/[USERNAME]/.bundle/app-config-no-deployment/default/files/app", "new": "/Workspace/Users/[USERNAME]/.bundle/app-config-no-deployment/default/files/app" } diff --git a/acceptance/bundle/resources/apps/config-no-deployment/script b/acceptance/bundle/resources/apps/config-no-deployment/script index 2e66002df66..f1970999645 100644 --- a/acceptance/bundle/resources/apps/config-no-deployment/script +++ b/acceptance/bundle/resources/apps/config-no-deployment/script @@ -3,8 +3,8 @@ echo "*" > .gitignore title "Deploy: app created with no_compute; config is not deployed until the app starts" trace $CLI bundle deploy -title "Plan is a no-op: config/source_code_path drift is skipped while the app has no deployment" +title "Plan is a no-op: config/source_code_path drift is skipped while the app has no active deployment" trace $CLI bundle plan | contains.py "Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged" -title "Both deploy-only fields are skipped with reason \"no deployment\"" +title "Both deploy-only fields are skipped with reason \"no active deployment\"" trace $CLI bundle plan -o json | jq '.plan[].changes | {config, source_code_path}' diff --git a/acceptance/bundle/resources/apps/git-source-no-deployment/output.txt b/acceptance/bundle/resources/apps/git-source-no-deployment/output.txt index 6a75163aa5e..77e8df548da 100644 --- a/acceptance/bundle/resources/apps/git-source-no-deployment/output.txt +++ b/acceptance/bundle/resources/apps/git-source-no-deployment/output.txt @@ -6,16 +6,16 @@ Deploying resources... Updating deployment state... Deployment complete! -=== Plan is a no-op: git_source drift is skipped while the app has no deployment +=== Plan is a no-op: git_source drift is skipped while the app has no active deployment >>> [CLI] bundle plan Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged -=== git_source is skipped with reason "no deployment" +=== git_source is skipped with reason "no active deployment" >>> [CLI] bundle plan -o json { "git_source": { "action": "skip", - "reason": "no deployment", + "reason": "no active deployment", "old": { "branch": "main" }, diff --git a/acceptance/bundle/resources/apps/git-source-no-deployment/script b/acceptance/bundle/resources/apps/git-source-no-deployment/script index d79e3cbef3a..63cce6312c3 100644 --- a/acceptance/bundle/resources/apps/git-source-no-deployment/script +++ b/acceptance/bundle/resources/apps/git-source-no-deployment/script @@ -3,8 +3,8 @@ echo "*" > .gitignore title "Deploy: app created with no_compute; git_source is not deployed until the app starts" trace $CLI bundle deploy -title "Plan is a no-op: git_source drift is skipped while the app has no deployment" +title "Plan is a no-op: git_source drift is skipped while the app has no active deployment" trace $CLI bundle plan | contains.py "Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged" -title "git_source is skipped with reason \"no deployment\"" +title "git_source is skipped with reason \"no active deployment\"" trace $CLI bundle plan -o json | jq '.plan[].changes | {git_source}' diff --git a/bundle/direct/dresources/app.go b/bundle/direct/dresources/app.go index 05b75005850..af80a420e91 100644 --- a/bundle/direct/dresources/app.go +++ b/bundle/direct/dresources/app.go @@ -246,22 +246,20 @@ func hasAppChanges(entry *PlanEntry) bool { return entry.Changes.HasChangeExcept("source_code_path", "config", "git_source", "lifecycle", "lifecycle.started") } -// OverrideChangeDesc skips drift on deploy-only fields (source_code_path, config, -// git_source) until the app has a deployment: they're applied via an app deployment -// (see manageLifecycle), so DoRead leaves them nil until then and any diff is -// spurious. Real out-of-band drift is reported again once a deployment exists. +// OverrideChangeDesc suppresses drift on the deploy-only fields (source_code_path, +// config, git_source) whenever the app has no active deployment. DoRead can only +// read these back from the active deployment, and the backend clears it whenever the +// app is stopped, not just before the first deploy. Without an active deployment the +// remote side is empty, so any diff against the configured value is spurious. Such a +// change is applied on the next start (see manageLifecycle), not lost; once a +// deployment exists, real out-of-band drift is reported normally. func (*ResourceApp) OverrideChangeDesc(_ context.Context, path *structpath.PathNode, change *ChangeDesc, remote *AppRemote) error { // Prefix(1) so a nested diff (e.g. config.command) matches its top-level field. switch path.Prefix(1).String() { - case "source_code_path": - if remote.SourceCodePath == "" || remote.SourceCodePath == "null" { - change.Action = deployplan.Skip - change.Reason = "no deployment" - } - case "config", "git_source": + case "source_code_path", "config", "git_source": if remote.ActiveDeployment == nil { change.Action = deployplan.Skip - change.Reason = "no deployment" + change.Reason = "no active deployment" } } return nil diff --git a/libs/testserver/apps.go b/libs/testserver/apps.go index 99382986d17..13fa8bd63b8 100644 --- a/libs/testserver/apps.go +++ b/libs/testserver/apps.go @@ -186,6 +186,11 @@ func (s *FakeWorkspace) AppsStop(_ Request, name string) Response { State: "UNAVAILABLE", Message: appStatusUnavailableMessage, } + // The backend clears the active/pending deployment when compute stops; only + // default_source_code_path is retained. Mirror that so drift detection against a + // stopped app matches cloud behavior. + app.ActiveDeployment = nil + app.PendingDeployment = nil s.Apps[name] = app return Response{Body: app} From c67e7f1633dcf01a3c556cb4f3983d0b881b2510 Mon Sep 17 00:00:00 2001 From: Rada Kamysheva Date: Fri, 17 Jul 2026 10:46:51 +0000 Subject: [PATCH 3/3] Scope stop-clears-deployment comments to non-scalable apps The active deployment is cleared on stop only for non-scalable apps; scalable apps retain it (pending is always cleared). Soften the OverrideChangeDesc and test-server comments accordingly. Comment-only, no behavior change. --- bundle/direct/dresources/app.go | 11 +++++------ libs/testserver/apps.go | 7 ++++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bundle/direct/dresources/app.go b/bundle/direct/dresources/app.go index af80a420e91..3e5ec74943b 100644 --- a/bundle/direct/dresources/app.go +++ b/bundle/direct/dresources/app.go @@ -247,12 +247,11 @@ func hasAppChanges(entry *PlanEntry) bool { } // OverrideChangeDesc suppresses drift on the deploy-only fields (source_code_path, -// config, git_source) whenever the app has no active deployment. DoRead can only -// read these back from the active deployment, and the backend clears it whenever the -// app is stopped, not just before the first deploy. Without an active deployment the -// remote side is empty, so any diff against the configured value is spurious. Such a -// change is applied on the next start (see manageLifecycle), not lost; once a -// deployment exists, real out-of-band drift is reported normally. +// config, git_source) when the app has no active deployment. DoRead reads these only +// from the active deployment, which is absent before the first deploy and, for +// non-scalable apps, cleared on stop. Without it the remote side is empty, so any +// diff is spurious; the change applies on the next start (see manageLifecycle). Real +// drift is still reported once a deployment exists. func (*ResourceApp) OverrideChangeDesc(_ context.Context, path *structpath.PathNode, change *ChangeDesc, remote *AppRemote) error { // Prefix(1) so a nested diff (e.g. config.command) matches its top-level field. switch path.Prefix(1).String() { diff --git a/libs/testserver/apps.go b/libs/testserver/apps.go index 13fa8bd63b8..a7d30345339 100644 --- a/libs/testserver/apps.go +++ b/libs/testserver/apps.go @@ -186,9 +186,10 @@ func (s *FakeWorkspace) AppsStop(_ Request, name string) Response { State: "UNAVAILABLE", Message: appStatusUnavailableMessage, } - // The backend clears the active/pending deployment when compute stops; only - // default_source_code_path is retained. Mirror that so drift detection against a - // stopped app matches cloud behavior. + // Non-scalable apps clear the active deployment on stop (pending is always + // cleared); only default_source_code_path is retained. The fixtures are all + // non-scalable, so clear unconditionally. Scalable apps retain it on stop, which + // this fake does not model. app.ActiveDeployment = nil app.PendingDeployment = nil s.Apps[name] = app