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/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..9b158ea51fd --- /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 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 active deployment" +>>> [CLI] bundle plan -o json +{ + "config": { + "action": "skip", + "reason": "no active 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 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 new file mode 100644 index 00000000000..f1970999645 --- /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 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 active 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..77e8df548da --- /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 active deployment +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged + +=== git_source is skipped with reason "no active deployment" +>>> [CLI] bundle plan -o json +{ + "git_source": { + "action": "skip", + "reason": "no active 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..63cce6312c3 --- /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 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 active 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..3e5ec74943b 100644 --- a/bundle/direct/dresources/app.go +++ b/bundle/direct/dresources/app.go @@ -246,12 +246,20 @@ 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 suppresses drift on the deploy-only fields (source_code_path, +// 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 { - 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", "config", "git_source": + if remote.ActiveDeployment == nil { + change.Action = deployplan.Skip + change.Reason = "no active deployment" + } } return nil } diff --git a/libs/testserver/apps.go b/libs/testserver/apps.go index 99382986d17..a7d30345339 100644 --- a/libs/testserver/apps.go +++ b/libs/testserver/apps.go @@ -186,6 +186,12 @@ func (s *FakeWorkspace) AppsStop(_ Request, name string) Response { State: "UNAVAILABLE", Message: appStatusUnavailableMessage, } + // 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 return Response{Body: app}