diff --git a/acceptance/bundle/invariant/no_drift/out.test.toml b/acceptance/bundle/invariant/no_drift/out.test.toml index 0c8462419cc..b075815398e 100644 --- a/acceptance/bundle/invariant/no_drift/out.test.toml +++ b/acceptance/bundle/invariant/no_drift/out.test.toml @@ -57,4 +57,5 @@ EnvMatrix.INPUT_CONFIG = [ "volume_path_job_ref.yml.tmpl", "volume_uppercase_name.yml.tmpl" ] +EnvMatrix.LOCAL_DIFF = ["", "--planmode=offline"] EnvMatrix.READPLAN = ["", "1"] diff --git a/acceptance/bundle/invariant/no_drift/script b/acceptance/bundle/invariant/no_drift/script index 481f64d9e74..5357c973f55 100644 --- a/acceptance/bundle/invariant/no_drift/script +++ b/acceptance/bundle/invariant/no_drift/script @@ -44,6 +44,8 @@ echo INPUT_CONFIG_OK # JSON plan asserts every action is "skip" -- a strict superset of the text # renderer's "Plan: 0 to add, 0 to change, 0 to delete" summary. -$CLI bundle plan -o json > LOG.planjson 2>LOG.planjson.err +# LOCAL_DIFF is empty or "--planmode=offline"; offline mode ignores the remote +# state of resources, so no drift means the local state saved by deploy matches config. +$CLI bundle plan $LOCAL_DIFF -o json > LOG.planjson 2>LOG.planjson.err cat LOG.planjson.err | contains.py '!panic' '!internal error' > /dev/null verify_no_drift.py LOG.planjson diff --git a/acceptance/bundle/invariant/no_drift/test.toml b/acceptance/bundle/invariant/no_drift/test.toml index ff8a66c196e..13a7ff6e87b 100644 --- a/acceptance/bundle/invariant/no_drift/test.toml +++ b/acceptance/bundle/invariant/no_drift/test.toml @@ -1 +1,6 @@ EnvMatrix.READPLAN = ["", "1"] + +# Run every config twice: once planning normally and once with --planmode=offline, +# which plans without reading remote state. The no-drift invariant must hold either +# way: after a deploy the local state saved by the engine must already match the config. +EnvMatrix.LOCAL_DIFF = ["", "--planmode=offline"] diff --git a/acceptance/bundle/local/basic/databricks.yml.tmpl b/acceptance/bundle/local/basic/databricks.yml.tmpl new file mode 100644 index 00000000000..40688ff660d --- /dev/null +++ b/acceptance/bundle/local/basic/databricks.yml.tmpl @@ -0,0 +1,14 @@ +bundle: + name: local-$UNIQUE_NAME + +resources: + jobs: + bar: + name: bar-$UNIQUE_NAME + + foo: + name: foo-$UNIQUE_NAME + tasks: + - task_key: run_bar + run_job_task: + job_id: ${resources.jobs.bar.id} diff --git a/acceptance/bundle/local/basic/out.test.toml b/acceptance/bundle/local/basic/out.test.toml new file mode 100644 index 00000000000..8b995e4d177 --- /dev/null +++ b/acceptance/bundle/local/basic/out.test.toml @@ -0,0 +1,4 @@ +Local = true +Cloud = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.READPLAN = ["", "1"] diff --git a/acceptance/bundle/local/basic/output.txt b/acceptance/bundle/local/basic/output.txt new file mode 100644 index 00000000000..2660526220f --- /dev/null +++ b/acceptance/bundle/local/basic/output.txt @@ -0,0 +1,117 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/local-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] bundle plan --planmode=offline +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +=== Job reads during 'bundle plan --planmode=offline': + +>>> print_requests.py --get //jobs/get + +=== After out-of-band rename of bar: --planmode=offline plan still shows no drift: + +>>> [CLI] bundle plan --planmode=offline +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +=== Job reads during --planmode=offline plan after drift: + +>>> print_requests.py --get //jobs/get + +=== Plan --planmode=offline as JSON: + +>>> [CLI] bundle plan --planmode=offline -o json +{ + "plan_version": 2, + "cli_version": "[CLI_VERSION]", + "lineage": "[UUID]", + "serial": 1, + "plan_mode": "offline", + "plan": { + "resources.jobs.bar": { + "action": "skip", + "prior_state": { + "deployment": { + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/local-[UNIQUE_NAME]/default/state/metadata.json" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "bar-[UNIQUE_NAME]", + "queue": { + "enabled": true + } + } + }, + "resources.jobs.foo": { + "depends_on": [ + { + "node": "resources.jobs.bar", + "label": "${resources.jobs.bar.id}" + } + ], + "action": "skip", + "prior_state": { + "deployment": { + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/local-[UNIQUE_NAME]/default/state/metadata.json" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "foo-[UNIQUE_NAME]", + "queue": { + "enabled": true + }, + "tasks": [ + { + "run_job_task": { + "job_id": [BAR_ID] + }, + "task_key": "run_bar" + } + ] + } + } + } +} + +=== Job reads during 'bundle plan --planmode=offline -o json': + +>>> print_requests.py --get //jobs/get + +=== Deploy --plan with --planmode=offline plan (warns): + +>>> [CLI] bundle deploy --planmode=offline +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/local-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== Job reads during 'bundle deploy --planmode=offline': + +>>> print_requests.py --get //jobs/get --keep +{ + "method": "GET", + "path": "/api/2.2/jobs/get", + "q": { + "job_id": "[BAR_ID]" + } +} + +=== Telemetry: +plan_mode_used_offline true + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.jobs.bar + delete resources.jobs.foo + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/local-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/local/basic/script b/acceptance/bundle/local/basic/script new file mode 100644 index 00000000000..b55c3a97cde --- /dev/null +++ b/acceptance/bundle/local/basic/script @@ -0,0 +1,62 @@ +envsubst '$UNIQUE_NAME' < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +# Deploy both jobs so that they are recorded in the local state. foo references +# ${resources.jobs.bar.id}. +trace $CLI bundle deploy +rm -f out.requests.txt + +BAR_ID=$(read_id.py bar) + +# A --planmode=offline plan reports no drift without reading the remote state of the jobs: +# no GET is issued. The ${resources.jobs.bar.id} reference still resolves because +# it comes from the local state, not a remote read. +trace $CLI bundle plan --planmode=offline +title "Job reads during 'bundle plan --planmode=offline':\n" +trace print_requests.py --get //jobs/get + +# Rename bar out-of-band (simulates remote drift). --planmode=offline plan still shows +# no changes: it never reads remote state, so the rename is invisible to it. +edit_resource.py jobs $BAR_ID <<'EOF' +r["name"] = r["name"] + "-drifted" +EOF +rm -f out.requests.txt + +title "After out-of-band rename of bar: --planmode=offline plan still shows no drift:\n" +trace $CLI bundle plan --planmode=offline +title "Job reads during --planmode=offline plan after drift:\n" +trace print_requests.py --get //jobs/get + +# The JSON plan is self-describing: plan_mode is "offline", so deploy --plan can +# warn before applying it. remote_state is absent (nothing was fetched); prior_state +# carries the last saved local state and is what drift classification and reference +# resolution read in offline mode. +title "Plan --planmode=offline as JSON:\n" +trace $CLI bundle plan --planmode=offline -o json | tee plan.local.json +title "Job reads during 'bundle plan --planmode=offline -o json':\n" +trace print_requests.py --get //jobs/get + +# deploy --plan with a --planmode=offline plan warns that the plan may miss out-of-band drift. +# READPLAN matrix runs this twice: once applying the saved plan (warns), once without. +title "Deploy --plan with --planmode=offline plan (warns):\n" +$CLI bundle deploy $(readplanarg plan.local.json) > LOG.deploy_plan 2>&1 +# contains.py echoes stdin, so route its passthrough to a LOG file: the deploy +# output (and the --planmode=offline warning, present only when READPLAN=1) must not leak +# into output.txt or the two matrix variants would diverge. +cat LOG.deploy_plan | contains.py '!panic' '!internal error' > LOG.contains +if [[ -n "$READPLAN" ]]; then + cat LOG.deploy_plan | contains.py 'does not reflect the remote state' > LOG.contains +fi +rm -f out.requests.txt + +# A --planmode=offline deploy likewise does not read remote state up front and reports its use via telemetry. +trace $CLI bundle deploy --planmode=offline +title "Job reads during 'bundle deploy --planmode=offline':\n" +trace print_requests.py --get //jobs/get --keep +title "Telemetry:\n" +print_telemetry_bool_values | grep '^plan_mode_used_offline ' diff --git a/acceptance/bundle/local/basic/test.toml b/acceptance/bundle/local/basic/test.toml new file mode 100644 index 00000000000..512ddb580e2 --- /dev/null +++ b/acceptance/bundle/local/basic/test.toml @@ -0,0 +1,9 @@ +Local = true +Cloud = true +RecordRequests = true +# --planmode=offline skips the per-resource remote read, which only the direct engine performs. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +# READPLAN=1 feeds the saved local plan to deploy --plan; the warning is asserted in script. +EnvMatrix.READPLAN = ["", "1"] +# databricks.yml is generated at runtime from the template. +Ignore = [".databricks", ".gitignore", "databricks.yml", "plan.local.json"] diff --git a/acceptance/bundle/local/grants-remove/databricks.yml.tmpl b/acceptance/bundle/local/grants-remove/databricks.yml.tmpl new file mode 100644 index 00000000000..486df7ba635 --- /dev/null +++ b/acceptance/bundle/local/grants-remove/databricks.yml.tmpl @@ -0,0 +1,15 @@ +bundle: + name: grants-remove-$UNIQUE_NAME + +resources: + schemas: + myschema: + catalog_name: main + name: schema_grants_remove_$UNIQUE_NAME + grants: + - principal: account users + privileges: + - USE_SCHEMA + - principal: deco-test-user@databricks.com # TO_DELETE + privileges: # TO_DELETE + - USE_SCHEMA # TO_DELETE diff --git a/acceptance/bundle/local/grants-remove/out.test.toml b/acceptance/bundle/local/grants-remove/out.test.toml new file mode 100644 index 00000000000..e90b6d5d1ba --- /dev/null +++ b/acceptance/bundle/local/grants-remove/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/local/grants-remove/output.txt b/acceptance/bundle/local/grants-remove/output.txt new file mode 100644 index 00000000000..77b9e66ea10 --- /dev/null +++ b/acceptance/bundle/local/grants-remove/output.txt @@ -0,0 +1,51 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/grants-remove-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] bundle deploy --planmode=offline +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/grants-remove-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== Permissions requests during 'bundle deploy --planmode=offline': + +>>> print_requests.py //permissions +{ + "method": "PATCH", + "path": "/api/2.1/unity-catalog/permissions/schema/main.schema_grants_remove_[UNIQUE_NAME]", + "body": { + "changes": [ + { + "add": [ + "USE_SCHEMA" + ], + "principal": "account users", + "remove": [ + "ALL_PRIVILEGES" + ] + }, + { + "principal": "deco-test-user@databricks.com", + "remove": [ + "ALL_PRIVILEGES" + ] + } + ] + } +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.schemas.myschema + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.myschema + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/grants-remove-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/local/grants-remove/script b/acceptance/bundle/local/grants-remove/script new file mode 100644 index 00000000000..44c52385e87 --- /dev/null +++ b/acceptance/bundle/local/grants-remove/script @@ -0,0 +1,20 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +# Full-mode deploy: two principals granted. +trace $CLI bundle deploy +rm -f out.requests.txt + +# Remove the second principal by stripping lines marked "# TO_DELETE". +# In offline mode entry.RemoteState is nil, so removedGrantPrincipals reads +# from entry.PriorState to compute the removal set from the last saved state. +grep -v '# TO_DELETE' databricks.yml > databricks.yml.new && mv databricks.yml.new databricks.yml + +trace $CLI bundle deploy --planmode=offline +title "Permissions requests during 'bundle deploy --planmode=offline':\n" +trace print_requests.py //permissions diff --git a/acceptance/bundle/local/grants-remove/test.toml b/acceptance/bundle/local/grants-remove/test.toml new file mode 100644 index 00000000000..b59517a51f2 --- /dev/null +++ b/acceptance/bundle/local/grants-remove/test.toml @@ -0,0 +1,7 @@ +Local = true +Cloud = false +RecordRequests = true +# --planmode=offline skips the per-resource remote read, which only the direct engine performs. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +Ignore = [".databricks", "databricks.yml"] diff --git a/acceptance/bundle/local/lifecycle-skip/databricks.yml.tmpl b/acceptance/bundle/local/lifecycle-skip/databricks.yml.tmpl new file mode 100644 index 00000000000..fa1f8465a34 --- /dev/null +++ b/acceptance/bundle/local/lifecycle-skip/databricks.yml.tmpl @@ -0,0 +1,16 @@ +bundle: + name: lifecycle-skip-$UNIQUE_NAME + +workspace: + root_path: ~/.bundle/$UNIQUE_NAME + +resources: + clusters: + mycluster: + cluster_name: $UNIQUE_NAME + spark_version: 15.4.x-scala2.12 + node_type_id: $NODE_TYPE_ID + instance_pool_id: $TEST_INSTANCE_POOL_ID + num_workers: 1 + lifecycle: + started: false diff --git a/acceptance/bundle/local/lifecycle-skip/out.test.toml b/acceptance/bundle/local/lifecycle-skip/out.test.toml new file mode 100644 index 00000000000..9cfad3fb0d5 --- /dev/null +++ b/acceptance/bundle/local/lifecycle-skip/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/local/lifecycle-skip/output.txt b/acceptance/bundle/local/lifecycle-skip/output.txt new file mode 100644 index 00000000000..0b4e69a8b4d --- /dev/null +++ b/acceptance/bundle/local/lifecycle-skip/output.txt @@ -0,0 +1,44 @@ + +>>> errcode [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/[UNIQUE_NAME]/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> update_file.py databricks.yml started: false started: true + +>>> errcode [CLI] bundle deploy --planmode=offline +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/[UNIQUE_NAME]/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== Cluster requests during 'bundle deploy --planmode=offline' (should be empty): + +>>> print_requests.py //clusters + +>>> errcode [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/[UNIQUE_NAME]/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== Cluster requests during full-mode deploy that follows (Start expected): + +>>> print_requests.py //clusters +{ + "method": "POST", + "path": "/api/2.1/clusters/start", + "body": { + "cluster_id": "[UUID]" + } +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.clusters.mycluster + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/[UNIQUE_NAME] + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/local/lifecycle-skip/script b/acceptance/bundle/local/lifecycle-skip/script new file mode 100644 index 00000000000..1231dd464dc --- /dev/null +++ b/acceptance/bundle/local/lifecycle-skip/script @@ -0,0 +1,25 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +# Full-mode deploy: cluster created and stopped per lifecycle.started=false. +trace errcode $CLI bundle deploy +rm -f out.requests.txt + +# Toggle to started=true; redeploy under --planmode=offline. +# In offline mode entry.RemoteState is nil, so DoUpdate must skip the Start call +# entirely and WaitAfterUpdate must skip its poll. Nothing else has changed, so +# no /clusters requests should be issued at all. +trace update_file.py databricks.yml "started: false" "started: true" +trace errcode $CLI bundle deploy --planmode=offline +title "Cluster requests during 'bundle deploy --planmode=offline' (should be empty):\n" +trace print_requests.py //clusters + +# The next full-mode deploy applies the real transition. +trace errcode $CLI bundle deploy +title "Cluster requests during full-mode deploy that follows (Start expected):\n" +trace print_requests.py //clusters diff --git a/acceptance/bundle/local/lifecycle-skip/test.toml b/acceptance/bundle/local/lifecycle-skip/test.toml new file mode 100644 index 00000000000..2e7993b8491 --- /dev/null +++ b/acceptance/bundle/local/lifecycle-skip/test.toml @@ -0,0 +1,11 @@ +Local = true +Cloud = true +RecordRequests = true +# --planmode=offline skips the per-resource remote read, which only the direct engine performs. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +Ignore = [".databricks", "databricks.yml"] + +[[Repls]] +Old = "[0-9]{4}-[0-9]{6}-[0-9a-z]{8}" +New = "[UUID]" diff --git a/acceptance/bundle/local/references/databricks.yml.tmpl b/acceptance/bundle/local/references/databricks.yml.tmpl new file mode 100644 index 00000000000..d888adbd8e9 --- /dev/null +++ b/acceptance/bundle/local/references/databricks.yml.tmpl @@ -0,0 +1,18 @@ +bundle: + name: local-references-$UNIQUE_NAME + +resources: + jobs: + bar: + name: bar-$UNIQUE_NAME + + foo: + name: foo-$UNIQUE_NAME + tasks: + - task_key: tag_owner + notebook_task: + # creator_user_name is a remote-only field: it exists in the remote + # schema but not in config. Under --planmode=offline, bar's remote + # state is never fetched; instead foo.notebook_path's last resolved + # value is carried forward from foo's saved state. + notebook_path: /path/${resources.jobs.bar.creator_user_name} diff --git a/acceptance/bundle/local/references/out.test.toml b/acceptance/bundle/local/references/out.test.toml new file mode 100644 index 00000000000..9cfad3fb0d5 --- /dev/null +++ b/acceptance/bundle/local/references/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/local/references/output.txt b/acceptance/bundle/local/references/output.txt new file mode 100644 index 00000000000..e6dabf4e39d --- /dev/null +++ b/acceptance/bundle/local/references/output.txt @@ -0,0 +1,40 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/local-references-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] bundle plan --planmode=offline +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +=== Job reads during 'bundle plan --planmode=offline': + +>>> print_requests.py --get //jobs/get + +=== After out-of-band rename of bar: --planmode=offline plan still shows no drift: + +>>> [CLI] bundle plan --planmode=offline +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +=== Job reads during --planmode=offline plan after drift: + +>>> print_requests.py --get //jobs/get + +=== Normal plan detects the drift: + +>>> [CLI] bundle plan +update jobs.bar +update jobs.foo + +Plan: 0 to add, 2 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.jobs.bar + delete resources.jobs.foo + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/local-references-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/local/references/script b/acceptance/bundle/local/references/script new file mode 100644 index 00000000000..d67ec93e65e --- /dev/null +++ b/acceptance/bundle/local/references/script @@ -0,0 +1,39 @@ +envsubst '$UNIQUE_NAME' < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +# Deploy so that bar's ID and creator_user_name are recorded in the local state. +trace $CLI bundle deploy +rm -f out.requests.txt + +BAR_ID=$(read_id.py bar) + +# A --planmode=offline plan skips remote reads entirely. foo.tasks[0].notebook_path +# references bar.creator_user_name, a remote-only field. Offline resolution carries +# forward foo.notebook_path's last resolved value from foo's saved state (Method A, +# source-side), so no GET is issued and the reference resolves successfully. +trace $CLI bundle plan --planmode=offline +title "Job reads during 'bundle plan --planmode=offline':\n" +trace print_requests.py --get //jobs/get + +# Rename bar out-of-band (simulates remote drift). +edit_resource.py jobs $BAR_ID <<'EOF' +r["name"] = r["name"] + "-drifted" +EOF +rm -f out.requests.txt + +# --planmode=offline plan still reports no drift: it compares config against saved local +# state and never reads the remote, so the out-of-band rename is invisible. +title "After out-of-band rename of bar: --planmode=offline plan still shows no drift:\n" +trace $CLI bundle plan --planmode=offline +title "Job reads during --planmode=offline plan after drift:\n" +trace print_requests.py --get //jobs/get + +# Normal plan reads remote and detects the drift. +title "Normal plan detects the drift:\n" +trace $CLI bundle plan +rm -f out.requests.txt diff --git a/acceptance/bundle/local/references/test.toml b/acceptance/bundle/local/references/test.toml new file mode 100644 index 00000000000..9ace7dd51f0 --- /dev/null +++ b/acceptance/bundle/local/references/test.toml @@ -0,0 +1,7 @@ +Local = true +Cloud = true +RecordRequests = true +# --planmode=offline skips the per-resource remote read, which only the direct engine performs. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +# databricks.yml is generated at runtime from the template. +Ignore = [".databricks", ".gitignore", "databricks.yml"] diff --git a/acceptance/bundle/local/rejected/databricks.yml b/acceptance/bundle/local/rejected/databricks.yml new file mode 100644 index 00000000000..f3c9c652e4e --- /dev/null +++ b/acceptance/bundle/local/rejected/databricks.yml @@ -0,0 +1,7 @@ +bundle: + name: local-rejected + +resources: + jobs: + my_job: + name: my-job diff --git a/acceptance/bundle/local/rejected/out.test.toml b/acceptance/bundle/local/rejected/out.test.toml new file mode 100644 index 00000000000..65156e0457c --- /dev/null +++ b/acceptance/bundle/local/rejected/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform"] diff --git a/acceptance/bundle/local/rejected/output.txt b/acceptance/bundle/local/rejected/output.txt new file mode 100644 index 00000000000..18d20a3b136 --- /dev/null +++ b/acceptance/bundle/local/rejected/output.txt @@ -0,0 +1,17 @@ + +>>> [CLI] bundle plan --planmode=offline +Error: --planmode=offline is only supported with the direct engine. See https://docs.databricks.com/aws/en/dev-tools/bundles/direct + + +Exit code: 1 + +>>> [CLI] bundle deploy --planmode=offline +Error: --planmode=offline is only supported with the direct engine. See https://docs.databricks.com/aws/en/dev-tools/bundles/direct + + +Exit code: 1 + +>>> [CLI] bundle plan --planmode=bogus +Error: invalid --planmode "bogus" (want full or offline) + +Exit code: 1 diff --git a/acceptance/bundle/local/rejected/script b/acceptance/bundle/local/rejected/script new file mode 100644 index 00000000000..99a4cd620a0 --- /dev/null +++ b/acceptance/bundle/local/rejected/script @@ -0,0 +1,6 @@ +# --planmode=offline is only supported by the direct engine; both plan and deploy reject it. +errcode trace $CLI bundle plan --planmode=offline +errcode trace $CLI bundle deploy --planmode=offline + +# Unknown --planmode values are rejected with an actionable error. +errcode trace $CLI bundle plan --planmode=bogus diff --git a/acceptance/bundle/local/rejected/test.toml b/acceptance/bundle/local/rejected/test.toml new file mode 100644 index 00000000000..e81b1480056 --- /dev/null +++ b/acceptance/bundle/local/rejected/test.toml @@ -0,0 +1,2 @@ +# --planmode=offline is rejected on the terraform engine with an actionable error. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform"] diff --git a/acceptance/bundle/resources/catalogs/basic/out.test.toml b/acceptance/bundle/resources/catalogs/basic/out.test.toml index 5e66915254d..9ce94d7dbf4 100644 --- a/acceptance/bundle/resources/catalogs/basic/out.test.toml +++ b/acceptance/bundle/resources/catalogs/basic/out.test.toml @@ -4,3 +4,4 @@ RequiresUnityCatalog = true GOOSOnPR.darwin = false GOOSOnPR.windows = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.PLANMODE = ["", "--planmode=offline"] diff --git a/acceptance/bundle/resources/catalogs/basic/script b/acceptance/bundle/resources/catalogs/basic/script index 3ce45404638..d8fb43d0934 100644 --- a/acceptance/bundle/resources/catalogs/basic/script +++ b/acceptance/bundle/resources/catalogs/basic/script @@ -12,7 +12,7 @@ cleanup() { trap cleanup EXIT title "Deploy bundle with catalog" -trace $CLI bundle deploy +trace $CLI bundle deploy $PLANMODE title "Assert the catalog is created" trace $CLI catalogs get "${CATALOG_NAME}" | jq "{name, comment, properties}" @@ -21,7 +21,7 @@ title "Update catalog comment" update_file.py databricks.yml "This catalog was created from DABs" "Updated comment from DABs" title "Redeploy with updated comment" -trace $CLI bundle deploy +trace $CLI bundle deploy $PLANMODE title "Assert the catalog comment is updated" trace $CLI catalogs get "${CATALOG_NAME}" | jq "{name, comment}" diff --git a/acceptance/bundle/resources/catalogs/basic/test.toml b/acceptance/bundle/resources/catalogs/basic/test.toml index d2b122411f2..78396971185 100644 --- a/acceptance/bundle/resources/catalogs/basic/test.toml +++ b/acceptance/bundle/resources/catalogs/basic/test.toml @@ -8,5 +8,14 @@ Ignore = [ "databricks.yml", ] +# Also run with --planmode=offline: plan/deploy issue the same requests with or without --planmode=offline +# so the recorded output is identical across variants. +EnvRepl.PLANMODE = false + [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] + PLANMODE = ["", "--planmode=offline"] + +[[Repls]] +Old = " --planmode=offline" +New = "" diff --git a/acceptance/bundle/resources/catalogs/with-schemas/out.test.toml b/acceptance/bundle/resources/catalogs/with-schemas/out.test.toml index 5e66915254d..9ce94d7dbf4 100644 --- a/acceptance/bundle/resources/catalogs/with-schemas/out.test.toml +++ b/acceptance/bundle/resources/catalogs/with-schemas/out.test.toml @@ -4,3 +4,4 @@ RequiresUnityCatalog = true GOOSOnPR.darwin = false GOOSOnPR.windows = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.PLANMODE = ["", "--planmode=offline"] diff --git a/acceptance/bundle/resources/catalogs/with-schemas/script b/acceptance/bundle/resources/catalogs/with-schemas/script index 66930927295..450637df32a 100644 --- a/acceptance/bundle/resources/catalogs/with-schemas/script +++ b/acceptance/bundle/resources/catalogs/with-schemas/script @@ -16,7 +16,7 @@ cleanup() { trap cleanup EXIT title "Deploy bundle with catalog and schema" -trace $CLI bundle deploy +trace $CLI bundle deploy $PLANMODE title "Assert the catalog is created" trace $CLI catalogs get "${CATALOG_NAME}" | jq "{name, comment, properties}" @@ -36,7 +36,7 @@ title "Update catalog comment" update_file.py databricks.yml "Catalog created from DABs" "Updated catalog comment" title "Redeploy with updated catalog" -trace $CLI bundle deploy +trace $CLI bundle deploy $PLANMODE title "Assert catalog comment is updated" trace $CLI catalogs get "${CATALOG_NAME}" | jq "{name, comment}" diff --git a/acceptance/bundle/resources/catalogs/with-schemas/test.toml b/acceptance/bundle/resources/catalogs/with-schemas/test.toml index d2b122411f2..78396971185 100644 --- a/acceptance/bundle/resources/catalogs/with-schemas/test.toml +++ b/acceptance/bundle/resources/catalogs/with-schemas/test.toml @@ -8,5 +8,14 @@ Ignore = [ "databricks.yml", ] +# Also run with --planmode=offline: plan/deploy issue the same requests with or without --planmode=offline +# so the recorded output is identical across variants. +EnvRepl.PLANMODE = false + [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] + PLANMODE = ["", "--planmode=offline"] + +[[Repls]] +Old = " --planmode=offline" +New = "" diff --git a/acceptance/bundle/resources/external_locations/out.test.toml b/acceptance/bundle/resources/external_locations/out.test.toml index dacaf56fd57..13b6e71aa86 100644 --- a/acceptance/bundle/resources/external_locations/out.test.toml +++ b/acceptance/bundle/resources/external_locations/out.test.toml @@ -4,3 +4,4 @@ RequiresUnityCatalog = true GOOSOnPR.darwin = false GOOSOnPR.windows = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.PLANMODE = ["", "--planmode=offline"] diff --git a/acceptance/bundle/resources/external_locations/script b/acceptance/bundle/resources/external_locations/script index 1ff7d02ec4d..5c725f31598 100755 --- a/acceptance/bundle/resources/external_locations/script +++ b/acceptance/bundle/resources/external_locations/script @@ -12,8 +12,8 @@ cleanup() { trap cleanup EXIT title "Deploy bundle with catalog and external location" -trace $CLI bundle plan -trace $CLI bundle deploy +trace $CLI bundle plan $PLANMODE +trace $CLI bundle deploy $PLANMODE title "Assert the catalog is created with grants" trace $CLI catalogs get "${CATALOG_NAME}" | jq "{name, comment, properties}" @@ -27,8 +27,8 @@ title "Update external location comment" update_file.py databricks.yml "Test external location from DABs" "Updated external location from DABs" title "Redeploy with updated comment" -trace $CLI bundle plan -trace $CLI bundle deploy +trace $CLI bundle plan $PLANMODE +trace $CLI bundle deploy $PLANMODE title "Assert the external location comment is updated" trace $CLI external-locations get "${EXT_LOCATION_NAME}" | jq "{name, comment}" @@ -37,8 +37,8 @@ title "Update catalog comment" update_file.py databricks.yml "Test catalog for external locations" "Updated catalog for external locations" title "Redeploy with updated catalog comment" -trace $CLI bundle plan -trace $CLI bundle deploy +trace $CLI bundle plan $PLANMODE +trace $CLI bundle deploy $PLANMODE title "Assert the catalog comment is updated" trace $CLI catalogs get "${CATALOG_NAME}" | jq "{name, comment}" diff --git a/acceptance/bundle/resources/external_locations/test.toml b/acceptance/bundle/resources/external_locations/test.toml index 55cc7b681f5..f2882c6bed6 100644 --- a/acceptance/bundle/resources/external_locations/test.toml +++ b/acceptance/bundle/resources/external_locations/test.toml @@ -10,5 +10,14 @@ Ignore = [ "databricks.yml", ] +# Also run with --planmode=offline: plan/deploy issue the same requests with or without --planmode=offline +# so the recorded output is identical across variants. +EnvRepl.PLANMODE = false + [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] + PLANMODE = ["", "--planmode=offline"] + +[[Repls]] +Old = " --planmode=offline" +New = "" diff --git a/acceptance/bundle/resources/jobs/num_workers/out.test.toml b/acceptance/bundle/resources/jobs/num_workers/out.test.toml index 67759662971..1ab3e827cef 100644 --- a/acceptance/bundle/resources/jobs/num_workers/out.test.toml +++ b/acceptance/bundle/resources/jobs/num_workers/out.test.toml @@ -3,3 +3,4 @@ Cloud = false GOOSOnPR.darwin = false GOOSOnPR.windows = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] +EnvMatrix.PLANMODE = ["", "--planmode=offline"] diff --git a/acceptance/bundle/resources/jobs/num_workers/script b/acceptance/bundle/resources/jobs/num_workers/script index 8e430e43063..d1819be1ecc 100644 --- a/acceptance/bundle/resources/jobs/num_workers/script +++ b/acceptance/bundle/resources/jobs/num_workers/script @@ -1,6 +1,6 @@ envsubst < databricks.yml.tmpl > databricks.yml -trace $CLI bundle deploy +trace $CLI bundle deploy $PLANMODE trace print_requests.py //jobs -trace $CLI bundle plan +trace $CLI bundle plan $PLANMODE rm out.requests.txt diff --git a/acceptance/bundle/resources/jobs/num_workers/test.toml b/acceptance/bundle/resources/jobs/num_workers/test.toml new file mode 100644 index 00000000000..bdda3285f8a --- /dev/null +++ b/acceptance/bundle/resources/jobs/num_workers/test.toml @@ -0,0 +1,12 @@ +# Also run plan/deploy with --planmode=offline (direct engine only; rejected on terraform). +# Deploy issues the same mutating requests with or without --planmode=offline — only the +# remote reads (GETs, excluded from print_requests.py) differ — so the recorded +# output is identical across variants. The " --planmode=offline" token is stripped from the +# command echo so the >>> lines match the non-plan-mode variant. +EnvMatrix.PLANMODE = ["", "--planmode=offline"] +EnvMatrixExclude.no_local_on_terraform = ["PLANMODE=--planmode=offline", "DATABRICKS_BUNDLE_ENGINE=terraform"] +EnvRepl.PLANMODE = false + +[[Repls]] +Old = " --planmode=offline" +New = "" diff --git a/acceptance/bundle/resources/schemas/update/out.test.toml b/acceptance/bundle/resources/schemas/update/out.test.toml index 67759662971..1ab3e827cef 100644 --- a/acceptance/bundle/resources/schemas/update/out.test.toml +++ b/acceptance/bundle/resources/schemas/update/out.test.toml @@ -3,3 +3,4 @@ Cloud = false GOOSOnPR.darwin = false GOOSOnPR.windows = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] +EnvMatrix.PLANMODE = ["", "--planmode=offline"] diff --git a/acceptance/bundle/resources/schemas/update/script b/acceptance/bundle/resources/schemas/update/script index 733ed7eb9df..bcf7a58f51e 100644 --- a/acceptance/bundle/resources/schemas/update/script +++ b/acceptance/bundle/resources/schemas/update/script @@ -1,12 +1,12 @@ echo "*" > .gitignore -trace $CLI bundle deploy +trace $CLI bundle deploy $PLANMODE trace print_requests.py //unity read_state.py schemas schema1 id name catalog_name comment title "Update comment and re-deploy" trace update_file.py databricks.yml COMMENT1 COMMENT2 -trace $CLI bundle deploy +trace $CLI bundle deploy $PLANMODE # Why the first time request match for direct & terraform, the requests from second deploy no longer match: # Terraform also sends "enable_predictive_optimization": "INHERIT" which is remote value that it stored in the state. trace print_requests.py //unity | gron.py | grep -v enable_predictive_optimization @@ -14,7 +14,7 @@ read_state.py schemas schema1 id name catalog_name comment title "Restore comment to original value and re-deploy" trace update_file.py databricks.yml COMMENT2 COMMENT1 -trace $CLI bundle deploy +trace $CLI bundle deploy $PLANMODE trace print_requests.py //unity | gron.py | grep -v enable_predictive_optimization read_state.py schemas schema1 id name catalog_name comment diff --git a/acceptance/bundle/resources/schemas/update/test.toml b/acceptance/bundle/resources/schemas/update/test.toml new file mode 100644 index 00000000000..bdda3285f8a --- /dev/null +++ b/acceptance/bundle/resources/schemas/update/test.toml @@ -0,0 +1,12 @@ +# Also run plan/deploy with --planmode=offline (direct engine only; rejected on terraform). +# Deploy issues the same mutating requests with or without --planmode=offline — only the +# remote reads (GETs, excluded from print_requests.py) differ — so the recorded +# output is identical across variants. The " --planmode=offline" token is stripped from the +# command echo so the >>> lines match the non-plan-mode variant. +EnvMatrix.PLANMODE = ["", "--planmode=offline"] +EnvMatrixExclude.no_local_on_terraform = ["PLANMODE=--planmode=offline", "DATABRICKS_BUNDLE_ENGINE=terraform"] +EnvRepl.PLANMODE = false + +[[Repls]] +Old = " --planmode=offline" +New = "" diff --git a/acceptance/bundle/resources/volumes/change-name/out.test.toml b/acceptance/bundle/resources/volumes/change-name/out.test.toml index 67759662971..1ab3e827cef 100644 --- a/acceptance/bundle/resources/volumes/change-name/out.test.toml +++ b/acceptance/bundle/resources/volumes/change-name/out.test.toml @@ -3,3 +3,4 @@ Cloud = false GOOSOnPR.darwin = false GOOSOnPR.windows = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] +EnvMatrix.PLANMODE = ["", "--planmode=offline"] diff --git a/acceptance/bundle/resources/volumes/change-name/script b/acceptance/bundle/resources/volumes/change-name/script index ba4d63c4032..6c186c7255e 100644 --- a/acceptance/bundle/resources/volumes/change-name/script +++ b/acceptance/bundle/resources/volumes/change-name/script @@ -1,4 +1,4 @@ -trace $CLI bundle deploy +trace $CLI bundle deploy $PLANMODE trace print_requests.py //unity @@ -8,10 +8,10 @@ $CLI bundle summary -o json | jq .resources title "Update name" trace update_file.py databricks.yml myvolume mynewvolume -trace $CLI bundle plan +trace $CLI bundle plan $PLANMODE # terraform marks this as "update", direct marks this as "update_with_id" $CLI bundle plan -o json > out.plan.$DATABRICKS_BUNDLE_ENGINE.json -trace $CLI bundle deploy +trace $CLI bundle deploy $PLANMODE trace print_requests.py //unity trace $CLI volumes read main.myschema.mynewvolume diff --git a/acceptance/bundle/resources/volumes/change-name/test.toml b/acceptance/bundle/resources/volumes/change-name/test.toml index bd4b66fe75e..7140d4bc981 100644 --- a/acceptance/bundle/resources/volumes/change-name/test.toml +++ b/acceptance/bundle/resources/volumes/change-name/test.toml @@ -1,3 +1,17 @@ Ignore = [ ".databricks", ] + +# Also run plan/deploy with --planmode=offline (direct engine only; rejected on terraform). +# Deploy issues the same mutating requests with or without --planmode=offline — only the +# remote reads (GETs, excluded from print_requests.py) differ — so the recorded +# output is identical across variants. The " --planmode=offline" token is stripped from the +# command echo so the >>> lines match the non-plan-mode variant. Note: the JSON plan +# (`bundle plan -o json`) keeps no --planmode=offline: its remote_state would differ. +EnvMatrix.PLANMODE = ["", "--planmode=offline"] +EnvMatrixExclude.no_local_on_terraform = ["PLANMODE=--planmode=offline", "DATABRICKS_BUNDLE_ENGINE=terraform"] +EnvRepl.PLANMODE = false + +[[Repls]] +Old = " --planmode=offline" +New = "" diff --git a/bundle/bundle.go b/bundle/bundle.go index 94f55389b26..1d726e148d3 100644 --- a/bundle/bundle.go +++ b/bundle/bundle.go @@ -15,6 +15,7 @@ import ( "time" "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/direct" "github.com/databricks/cli/bundle/env" "github.com/databricks/cli/bundle/metadata" @@ -162,6 +163,10 @@ type Bundle struct { // When non-empty, only the specified resources are included in deployment. Select []string + // PlanMode is the plan-computation mode selected via --planmode. Empty is + // the default (full remote-aware planning). See deployplan.PlanMode. + PlanMode deployplan.PlanMode + // SkipLocalFileValidation makes path translation tolerant of missing local files. // When set, TranslatePaths computes workspace paths without verifying files exist. // Used by config-remote-sync: a user may modify resource paths remotely (e.g., diff --git a/bundle/deployplan/plan.go b/bundle/deployplan/plan.go index bb1b0d77bff..c42adf1bb7a 100644 --- a/bundle/deployplan/plan.go +++ b/bundle/deployplan/plan.go @@ -15,12 +15,48 @@ import ( const currentPlanVersion = 2 +// PlanMode determines how much the planner talks to the workspace when +// computing a plan. +// +// - Full (default): the per-resource remote read runs; drift detection is +// complete. +// - Offline: no remote reads at all during plan. Drift classification runs +// against saved local state. Cross-resource references resolve from saved +// state: the dependent's own last-resolved value, or by looking up each +// `${...}` component in the target's saved state; otherwise the reference +// stays unresolved and is resolved after apply. +type PlanMode string + +const ( + PlanModeFull PlanMode = "" + PlanModeOffline PlanMode = "offline" +) + +// ParsePlanMode parses the value of the --planmode flag. An empty value is +// PlanModeFull; "full" and "offline" are accepted. Unknown values produce an +// actionable error. +func ParsePlanMode(s string) (PlanMode, error) { + switch s { + case "", "full": + return PlanModeFull, nil + case "offline": + return PlanModeOffline, nil + default: + return "", fmt.Errorf("invalid --planmode %q (want full or offline)", s) + } +} + type Plan struct { - PlanVersion int `json:"plan_version,omitempty"` - CLIVersion string `json:"cli_version,omitempty"` - Lineage string `json:"lineage,omitempty"` - Serial int `json:"serial,omitempty"` - Plan map[string]*PlanEntry `json:"plan,omitzero"` + PlanVersion int `json:"plan_version,omitempty"` + CLIVersion string `json:"cli_version,omitempty"` + Lineage string `json:"lineage,omitempty"` + Serial int `json:"serial,omitempty"` + + // Mode records how the plan was computed. Consumers like "deploy --plan" + // warn before applying a non-Full plan since it may miss out-of-band drift. + Mode PlanMode `json:"plan_mode,omitempty"` + + Plan map[string]*PlanEntry `json:"plan,omitzero"` mutex sync.Mutex `json:"-"` lockmap lockmap `json:"-"` @@ -79,10 +115,25 @@ type PlanEntry struct { // Gone is set on Delete entries when planning confirmed the resource no longer // exists remotely. Applying such an entry only removes it from the state, without // calling the delete API, and approval prompts do not list it as a deletion. - Gone bool `json:"gone,omitempty"` - NewState *structvar.StructVarJSON `json:"new_state,omitempty"` - RemoteState any `json:"remote_state,omitempty"` - Changes Changes `json:"changes,omitempty"` + Gone bool `json:"gone,omitempty"` + NewState *structvar.StructVarJSON `json:"new_state,omitempty"` + + // PriorState is the last saved local state — always StateType. Populated only + // when the planner did not read remote (--planmode=offline); it serves as the + // state-shaped stand-in that apply-time consumers (removedGrantPrincipals, + // bind's etag lookup) fall back to when RemoteState is nil. In full mode a + // resource reaching Update has RemoteState set by construction, so PriorState + // would be unused and is omitted to keep the plan JSON compact. + PriorState any `json:"prior_state,omitempty"` + + // RemoteState is a freshly-read remote state — RemoteType. Nil when the plan + // was computed without reading remote (--planmode=offline). Consumers that + // need live-remote-only fields (cluster.State, app.ComputeStatus, model.ModelId) + // read this; the reasonable behavior on nil is to skip whatever depends on + // live status. + RemoteState any `json:"remote_state,omitempty"` + + Changes Changes `json:"changes,omitempty"` } type DependsOnEntry struct { diff --git a/bundle/direct/apply.go b/bundle/direct/apply.go index d227d7f564c..e64c100671f 100644 --- a/bundle/direct/apply.go +++ b/bundle/direct/apply.go @@ -42,7 +42,7 @@ func (d *DeploymentUnit) Deploy(ctx context.Context, db *dstate.DeploymentState, case deployplan.Update: return d.Update(ctx, db, oldID, newState, planEntry) case deployplan.UpdateWithID: - return d.UpdateWithID(ctx, db, oldID, newState) + return d.UpdateWithID(ctx, db, oldID, newState, planEntry) case deployplan.Resize: return d.Resize(ctx, db, oldID, newState, planEntry) default: @@ -152,7 +152,7 @@ func (d *DeploymentUnit) Update(ctx context.Context, db *dstate.DeploymentState, } waitRemoteState, err := retryOnTransient(ctx, func() (any, error) { - return d.Adapter.WaitAfterUpdate(ctx, id, newState) + return d.Adapter.WaitAfterUpdate(ctx, id, newState, planEntry) }) if err != nil { return fmt.Errorf("waiting after updating id=%s: %w", id, err) @@ -167,7 +167,7 @@ func (d *DeploymentUnit) Update(ctx context.Context, db *dstate.DeploymentState, return nil } -func (d *DeploymentUnit) UpdateWithID(ctx context.Context, db *dstate.DeploymentState, oldID string, newState any) error { +func (d *DeploymentUnit) UpdateWithID(ctx context.Context, db *dstate.DeploymentState, oldID string, newState any, planEntry *deployplan.PlanEntry) error { var newID string var remoteState any err := retryOnTransientErr(ctx, func() error { @@ -196,7 +196,7 @@ func (d *DeploymentUnit) UpdateWithID(ctx context.Context, db *dstate.Deployment } waitRemoteState, err := retryOnTransient(ctx, func() (any, error) { - return d.Adapter.WaitAfterUpdate(ctx, newID, newState) + return d.Adapter.WaitAfterUpdate(ctx, newID, newState, planEntry) }) if err != nil { return fmt.Errorf("waiting after updating id=%s: %w", newID, err) diff --git a/bundle/direct/bind.go b/bundle/direct/bind.go index 9760ce95666..3a34645a200 100644 --- a/bundle/direct/bind.go +++ b/bundle/direct/bind.go @@ -114,7 +114,7 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac os.Remove(tmpStatePath) return nil, err } - plan, err := b.CalculatePlan(ctx, client, configRoot) + plan, err := b.CalculatePlan(ctx, client, configRoot, deployplan.PlanModeFull) if err != nil { os.Remove(tmpStatePath) return nil, err @@ -136,11 +136,22 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac // detection (dashboards and genie spaces). The etag is not provided by the // user; it comes from remote. If we don't store it in state, we won't // detect remote drift correctly and the next plan shows a bogus update. - if (strings.Contains(resourceKey, ".dashboards.") || strings.Contains(resourceKey, ".genie_spaces.")) && entry != nil && entry.RemoteState != nil { - etag, err := structaccess.Get(entry.RemoteState, structpath.NewStringKey(nil, "etag")) - if err == nil && etag != nil { - if etagStr, ok := etag.(string); ok && etagStr != "" { - _ = structaccess.Set(sv.Value, structpath.NewStringKey(nil, "etag"), etagStr) + // + // Prefer RemoteState (fresh remote read, always populated here because + // bind runs in full mode). PriorState carries the same field for these + // resources (StateType == RemoteType), so it's a safe fallback if a + // future call path invokes bind without a fresh remote. + if strings.Contains(resourceKey, ".dashboards.") || strings.Contains(resourceKey, ".genie_spaces.") { + priorOrRemote := entry.RemoteState + if priorOrRemote == nil { + priorOrRemote = entry.PriorState + } + if entry != nil && priorOrRemote != nil { + etag, err := structaccess.Get(priorOrRemote, structpath.NewStringKey(nil, "etag")) + if err == nil && etag != nil { + if etagStr, ok := etag.(string); ok && etagStr != "" { + _ = structaccess.Set(sv.Value, structpath.NewStringKey(nil, "etag"), etagStr) + } } } } @@ -170,7 +181,7 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac os.Remove(tmpStatePath) return nil, err } - plan, err = b.CalculatePlan(ctx, client, configRoot) + plan, err = b.CalculatePlan(ctx, client, configRoot, deployplan.PlanModeFull) if _, ferr := b.StateDB.Finalize(ctx); ferr != nil { log.Warnf(ctx, "failed to finalize state: %v", ferr) } diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index 139012423ea..b0eb0945da1 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -72,13 +72,15 @@ func (b *DeploymentBundle) InitForApply(ctx context.Context, client *databricks. // Eagerly parse plan entries loaded from JSON: // - NewState.Value contains raw JSON bytes; parse into typed structs and cache. // - RemoteState is decoded as map[string]interface{}; round-trip through the - // adapter's StateType to recover the correct typed struct so that type - // assertions in resource adapters (e.g. entry.RemoteState.(*GrantsState)) + // adapter's RemoteType to recover the correct typed struct so that type + // assertions in resource adapters (e.g. entry.RemoteState.(*AppRemote)) // work identically whether the plan came from a file or from memory. + // - PriorState is likewise round-tripped through the adapter's StateType. for resourceKey, entry := range plan.Plan { hasNewState := entry.NewState != nil && len(entry.NewState.Value) > 0 hasRemoteState := entry.RemoteState != nil - if !hasNewState && !hasRemoteState { + hasPriorState := entry.PriorState != nil + if !hasNewState && !hasRemoteState && !hasPriorState { continue } @@ -96,27 +98,47 @@ func (b *DeploymentBundle) InitForApply(ctx context.Context, client *databricks. } if hasRemoteState { - data, err := json.Marshal(entry.RemoteState) + typed, err := reMaterialize(entry.RemoteState, adapter.RemoteType()) if err != nil { - return fmt.Errorf("re-serializing remote state for %s: %w", resourceKey, err) - } - // RemoteType() returns a pointer type (e.g. *AppRemote); Elem() gives - // the struct type so reflect.New produces a single pointer, not double. - typed := reflect.New(adapter.RemoteType().Elem()).Interface() - if err := json.Unmarshal(data, typed); err != nil { return fmt.Errorf("loading remote state for %s: %w", resourceKey, err) } entry.RemoteState = typed } + + if hasPriorState { + typed, err := reMaterialize(entry.PriorState, adapter.StateType()) + if err != nil { + return fmt.Errorf("loading prior state for %s: %w", resourceKey, err) + } + entry.PriorState = typed + } } b.Plan = plan return nil } +// reMaterialize round-trips a JSON-decoded map[string]interface{} through the +// given typed pointer so subsequent type assertions on the value succeed. +// ptrType must be a pointer type (e.g. *AppRemote); Elem() gives the struct +// type so reflect.New produces a single pointer, not double. +func reMaterialize(v any, ptrType reflect.Type) (any, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("re-serializing: %w", err) + } + typed := reflect.New(ptrType.Elem()).Interface() + if err := json.Unmarshal(data, typed); err != nil { + return nil, err + } + return typed, nil +} + // CalculatePlan computes the deployment plan by comparing local config against remote state. // StateDB must already be open for read before calling this function. -func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks.WorkspaceClient, configRoot *config.Root) (*deployplan.Plan, error) { +// +// mode controls how much of the remote state is consulted. See deployplan.PlanMode. +func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks.WorkspaceClient, configRoot *config.Root, mode deployplan.PlanMode) (*deployplan.Plan, error) { b.StateDB.AssertOpenedForRead() err := b.init(client) @@ -129,6 +151,8 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks return nil, fmt.Errorf("reading config: %w", err) } + plan.Mode = mode + offline := mode == deployplan.PlanModeOffline b.Plan = plan g, err := makeGraph(plan) @@ -178,6 +202,12 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks return false } + if offline { + // The resource is being deleted because it is absent from config; + // its remote status is irrelevant, so skip the remote read. + return true + } + remoteState, err := retryOnTransient(ctx, func() (any, error) { return adapter.DoRead(ctx, id) }) @@ -237,33 +267,61 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks return false } - remoteState, err := retryOnTransient(ctx, func() (any, error) { - return adapter.DoRead(ctx, dbentry.ID) - }) - if err != nil { - if apierr.IsMissing(err) { - remoteState = nil - } else { - logdiag.LogError(ctx, fmt.Errorf("%s: reading id=%q: %w", errorPrefix, dbentry.ID, err)) - return false - } + // PriorState is the last saved local state. It serves as the stand-in + // for remote when the planner does not read remote (--planmode=offline): + // apply-time consumers like removedGrantPrincipals fall back to it when + // entry.RemoteState is nil. In full mode a resource that reaches Update + // action has RemoteState populated by construction, so PriorState is + // unused; we skip writing it to keep the plan JSON compact. + if offline { + entry.PriorState = savedState } - // We have a choice whether to include remoteState or remoteStateComparable from below. - // Including remoteState because in the near future remoteState is expected to become a superset struct of remoteStateComparable - entry.RemoteState = remoteState - var action deployplan.ActionType var remoteDiff []structdiff.Change - var remoteStateComparable any - if remoteState != nil { - remoteStateComparable, err = adapter.RemapState(remoteState) + if !offline { + var remoteState any + remoteState, err = retryOnTransient(ctx, func() (any, error) { + return adapter.DoRead(ctx, dbentry.ID) + }) + if err != nil { + if apierr.IsMissing(err) { + remoteState = nil + } else { + logdiag.LogError(ctx, fmt.Errorf("%s: reading id=%q: %w", errorPrefix, dbentry.ID, err)) + return false + } + } + + // entry.RemoteState carries the raw remote (RemoteType). Apply-time + // consumers type-assert on it for remote-only fields (cluster.State, + // app.ComputeStatus). Left nil in --planmode=offline — apply-time + // consumers must handle nil by skipping their remote-only paths. + entry.RemoteState = remoteState + } + + // remoteStateComparable is the state-shaped remote used for drift + // classification. In full mode it comes from RemapState(remoteRead) — + // or is nil when the resource is missing remotely (a truthful signal + // that upstream drift-classification code needs to treat as "no remote"). + // In modes that skip the remote read, use savedState as the stand-in. + var remoteStateComparable any + if offline { + remoteStateComparable = savedState + } else if entry.RemoteState != nil { + remoteStateComparable, err = adapter.RemapState(entry.RemoteState) if err != nil { logdiag.LogError(ctx, fmt.Errorf("%s: interpreting remote state id=%q: %w", errorPrefix, dbentry.ID, err)) return false } + } + if remoteStateComparable != nil && !offline { + // In full mode with a live remote, compare the remapped remote + // against the desired state to detect out-of-band drift. In local + // mode remoteStateComparable is savedState which by construction + // matches the local diff, so there is nothing extra to detect. remoteDiff, err = structdiff.GetStructDiff(remoteStateComparable, sv.Value, adapter.KeyedSlices()) if err != nil { logdiag.LogError(ctx, fmt.Errorf("%s: diffing remote state: %w", errorPrefix, err)) @@ -277,23 +335,36 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks return false } - err = addPerFieldActions(ctx, adapter, entry.Changes, remoteState) + // In --local we pass nil as the raw remote param: it is typed as each + // resource's remote struct, which the saved state (a state-typed struct) + // is not. OverrideChangeDesc hooks read the reconciled value from + // ch.Remote (populated from saved state above) instead. + var rawRemote any + if !offline { + rawRemote = entry.RemoteState + } + err = addPerFieldActions(ctx, adapter, entry.Changes, rawRemote) if err != nil { logdiag.LogError(ctx, fmt.Errorf("%s: classifying changes: %w", errorPrefix, err)) return false } - if remoteState == nil { + if entry.RemoteState == nil && !offline { // Even if local action is "recreate" which is higher than "create", we should still pick "create" here // because we know remote does not exist. action = deployplan.Create } else { + // In local-only mode the action is derived purely from the local diff + // (saved state vs config); remote existence is not consulted. action = getMaxAction(entry.Changes) } - // Note, this unconditionally stores remoteState. However, it may updated post-deploy, so whether - // it can be used for variable resolution depends on several factors, see canReadRemoteCache in LookupReferencePreDeploy - b.RemoteStateCache.Store(resourceKey, remoteState) + // Note, remoteState may be updated post-deploy, so whether it can be used for + // variable resolution depends on several factors, see canReadRemoteCache in LookupReferencePreDeploy. + // In --local mode the store is skipped so remoteStateForRef fetches on demand (it keys off cache absence). + if !offline { + b.RemoteStateCache.Store(resourceKey, entry.RemoteState) + } // Validate that resources without DoUpdate don't have update actions if action == deployplan.Update && !adapter.HasDoUpdate() { @@ -309,6 +380,20 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks return nil, errors.New("planning failed") } + // In --local the main loop skips each resource's remote read, but reference + // resolution may still have fetched some targets on demand (remoteStateForRef + // stores them in RemoteStateCache). Surface those reads in the plan. This runs + // after the parallel walk so the write to entry.RemoteState is single-threaded: + // during the walk a target is fetched from a dependent's goroutine under the + // target's read lock, where writing its entry would race with sibling dependents. + if offline { + for key, entry := range plan.Plan { + if remoteState, ok := b.RemoteStateCache.Load(key); ok { + entry.RemoteState = remoteState + } + } + } + for _, entry := range plan.Plan { if entry.Action == deployplan.Skip { entry.NewState = nil @@ -772,12 +857,20 @@ func (b *DeploymentBundle) LookupReferencePreDeploy(ctx context.Context, path *s if configValidErr != nil && remoteValidErr == nil { // The field is only present in remote state schema. if canReadRemoteCache { - remoteState, ok := b.RemoteStateCache.Load(targetResourceKey) + remoteState, ok, err := b.remoteStateForRef(ctx, targetResourceKey, adapter) + if err != nil { + return nil, err + } if ok { return structaccess.Get(remoteState, fieldPath) - } else { - return nil, fmt.Errorf("internal error: no entry in remote state cache for %q (remote-only)", targetResourceKey) } + // --planmode=offline: fall back to the target's saved state. + // The field is remote-only, but state may carry the last observed + // value (state persists all fields the resource author declared). + if b.Plan.Mode == deployplan.PlanModeOffline { + return b.savedStateField(targetResourceKey, adapter, fieldPath) + } + return nil, fmt.Errorf("internal error: no entry in remote state cache for %q (remote-only)", targetResourceKey) } return nil, errDelayed } @@ -791,17 +884,93 @@ func (b *DeploymentBundle) LookupReferencePreDeploy(ctx context.Context, path *s } if canReadRemoteCache { - remoteState, ok := b.RemoteStateCache.Load(targetResourceKey) + remoteState, ok, err := b.remoteStateForRef(ctx, targetResourceKey, adapter) + if err != nil { + return nil, err + } if ok { return structaccess.Get(remoteState, fieldPath) - } else { - return nil, fmt.Errorf("internal error: no entry in remote state cache for %q", targetResourceKey) } + // --planmode=offline: fall back to the target's saved state. + if b.Plan.Mode == deployplan.PlanModeOffline { + return b.savedStateField(targetResourceKey, adapter, fieldPath) + } + return nil, fmt.Errorf("internal error: no entry in remote state cache for %q", targetResourceKey) } return nil, errDelayed } +// savedStateField reads a field from the target resource's last saved state +// (state DB). Used in --planmode=offline as the fallback for reference +// resolution when the cache has no remote entry. Returns errDelayed when the +// target has no saved state (never deployed) or the field is absent — the +// reference stays unresolved and will be resolved after apply. +func (b *DeploymentBundle) savedStateField(targetResourceKey string, adapter *dresources.Adapter, fieldPath *structpath.PathNode) (any, error) { + dbentry, ok := b.StateDB.GetResourceEntry(targetResourceKey) + if !ok || len(dbentry.State) == 0 { + return nil, errDelayed + } + savedState, err := parseState(adapter.StateType(), dbentry.State) + if err != nil { + return nil, fmt.Errorf("parsing saved state for %q: %w", targetResourceKey, err) + } + value, err := structaccess.Get(savedState, fieldPath) + if err != nil || value == nil { + return nil, errDelayed + } + return value, nil +} + +// resolveReferencesFromSavedState is the offline-mode source-side pass over +// sv.Refs: for each field with an unresolved reference, look up the field's +// prior value in the resource's own saved state. When present, write it into +// sv.Value and drop the entry from sv.Refs so the per-reference loop skips it. +// The template's individual ${...} components do not need to be evaluated +// because the source-side value already reflects the last resolution. +func (b *DeploymentBundle) resolveReferencesFromSavedState(ctx context.Context, resourceKey string, sv *structvar.StructVar) { + dbentry, ok := b.StateDB.GetResourceEntry(resourceKey) + if !ok || len(dbentry.State) == 0 { + return + } + adapter, err := b.getAdapterForKey(resourceKey) + if err != nil { + return + } + savedState, err := parseState(adapter.StateType(), dbentry.State) + if err != nil { + log.Debugf(ctx, "offline: parsing saved state for %q failed: %v", resourceKey, err) + return + } + for fieldPathStr := range sv.Refs { + fieldPath, err := structpath.ParsePath(fieldPathStr) + if err != nil { + continue + } + value, err := structaccess.Get(savedState, fieldPath) + if err != nil || value == nil { + continue + } + if err := structaccess.Set(sv.Value, fieldPath, value); err != nil { + log.Debugf(ctx, "offline: setting %s.%s from saved state failed: %v", resourceKey, fieldPathStr, err) + continue + } + delete(sv.Refs, fieldPathStr) + } +} + +// remoteStateForRef returns the remote state of a referenced target resource +// for reference resolution. The state was cached when the target was processed +// (targets precede their dependents in DAG order). A cache miss returns +// (nil, false, nil) and the caller surfaces its own internal error or falls +// back to the offline saved-state lookup path. +func (b *DeploymentBundle) remoteStateForRef(_ context.Context, targetResourceKey string, _ *dresources.Adapter) (any, bool, error) { + if remoteState, ok := b.RemoteStateCache.Load(targetResourceKey); ok { + return remoteState, true, nil + } + return nil, false, nil +} + // resolveReferences processes all references in entry.NewState.Refs. func (b *DeploymentBundle) resolveReferences(ctx context.Context, resourceKey string, entry *deployplan.PlanEntry, errorPrefix string, isPreDeploy bool) bool { sv, ok := b.StateCache.Load(resourceKey) @@ -810,6 +979,14 @@ func (b *DeploymentBundle) resolveReferences(ctx context.Context, resourceKey st return false } + // In offline mode, prefer to carry the last resolved value of each source + // field from the source's own saved state. That avoids depending on the + // target's remote-only fields being present in state and matches the + // migrate command's Method A resolution. + if isPreDeploy && b.Plan.Mode == deployplan.PlanModeOffline { + b.resolveReferencesFromSavedState(ctx, resourceKey, sv) + } + var resolved bool for fieldPathStr, refString := range sv.Refs { refs, ok := dynvar.NewRef(dyn.V(refString)) diff --git a/bundle/direct/dresources/adapter.go b/bundle/direct/dresources/adapter.go index 70a9f1f5e3d..0d5fce99765 100644 --- a/bundle/direct/dresources/adapter.go +++ b/bundle/direct/dresources/adapter.go @@ -72,7 +72,10 @@ type IResource interface { WaitAfterCreate(ctx context.Context, id string, newState any) (remoteState any, e error) // [Optional] WaitAfterUpdate waits for the resource to become ready after update. Returns optionally updated remote state. - WaitAfterUpdate(ctx context.Context, id string, newState any) (remoteState any, e error) + // entry is the plan entry being applied; implementations may inspect + // entry.RemoteState (nil in --planmode=offline) to decide whether a wait + // that depends on live remote status should be skipped. + WaitAfterUpdate(ctx context.Context, id string, newState any, entry *PlanEntry) (remoteState any, e error) // [Optional] WaitAfterDelete waits for the resource to be fully removed after DoDelete returns. // Useful for backends with asynchronous deletion: a follow-up create on the same name (recreate path) @@ -523,12 +526,12 @@ func (a *Adapter) WaitAfterCreate(ctx context.Context, id string, newState any) // WaitAfterUpdate waits for the resource to become ready after update. // If the resource doesn't implement this method, this is a no-op. // Returns the updated remoteState if available, otherwise returns nil. -func (a *Adapter) WaitAfterUpdate(ctx context.Context, id string, newState any) (any, error) { +func (a *Adapter) WaitAfterUpdate(ctx context.Context, id string, newState any, entry *PlanEntry) (any, error) { if a.waitAfterUpdate == nil { return nil, nil // no-op if not implemented } - outs, err := a.waitAfterUpdate.Call(ctx, id, newState) + outs, err := a.waitAfterUpdate.Call(ctx, id, newState, entry) if err != nil { return nil, err } diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index a960e168fec..e4a78c416df 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -1026,7 +1026,7 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W remappedState = remappedStateFromUpdate } - remoteStateFromWaitUpdate, err := adapter.WaitAfterUpdate(ctx, createdID, newState) + remoteStateFromWaitUpdate, err := adapter.WaitAfterUpdate(ctx, createdID, newState, &deployplan.PlanEntry{}) require.NoError(t, err) if remoteStateFromWaitUpdate != nil { remappedStateFromWaitUpdate, err := adapter.RemapState(remoteStateFromWaitUpdate) diff --git a/bundle/direct/dresources/app.go b/bundle/direct/dresources/app.go index c3928755818..7a4b94fdefa 100644 --- a/bundle/direct/dresources/app.go +++ b/bundle/direct/dresources/app.go @@ -196,6 +196,12 @@ func (r *ResourceApp) DoUpdate(ctx context.Context, id string, config *AppState, } } + // entry.RemoteState is nil in --planmode=offline: we have no live status, + // so skip lifecycle management (don't Start, don't Stop, don't redeploy code). + if entry.RemoteState == nil { + return nil, nil + } + return nil, r.manageLifecycle(ctx, id, config, remoteIsStarted(entry)) } @@ -250,11 +256,15 @@ func hasAppChanges(entry *PlanEntry) bool { // git_source) while the app has no active deployment. DoRead reads them only from the // active deployment, so before the first deploy (or once a stop clears it) the remote // side is empty and the diff is spurious; it applies on the next start (manageLifecycle). +// +// remote is nil in --planmode=offline (no plan-time DoRead) and when the resource +// does not exist remotely; treat that as "no active deployment" so the skip fires +// on the same path without a nil deref. 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", "config", "git_source": - if remote.ActiveDeployment == nil { + if remote == nil || remote.ActiveDeployment == nil { change.Action = deployplan.Skip change.Reason = "no active deployment" } diff --git a/bundle/direct/dresources/app_test.go b/bundle/direct/dresources/app_test.go index 444dfbd255e..ce813bf5810 100644 --- a/bundle/direct/dresources/app_test.go +++ b/bundle/direct/dresources/app_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + "github.com/databricks/cli/bundle/deployplan" + "github.com/databricks/cli/libs/structs/structpath" "github.com/databricks/cli/libs/testserver" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/service/apps" @@ -170,3 +172,32 @@ func TestAppDoUpdate_UpdateMaskHasAllFields(t *testing.T) { assert.Contains(t, allFields, field, "field %s is in UpdateMaskFields but not in apps.App struct", field) } } + +func TestAppOverrideChangeDescActiveDeployment(t *testing.T) { + r := &ResourceApp{} + + // The hook skips drift on the deploy-only fields (source_code_path, config, + // git_source) when the app has no active deployment. remote may be a typed + // nil (--planmode=offline or resource missing remotely); the hook must treat + // that as "no active deployment" without dereferencing. + tests := []struct { + name string + path *structpath.PathNode + remote *AppRemote + wantAction deployplan.ActionType + }{ + {"source_code_path skips when remote is nil", structpath.MustParsePath("source_code_path"), nil, deployplan.Skip}, + {"config.command skips when remote is nil", structpath.MustParsePath("config.command"), nil, deployplan.Skip}, + {"git_source skips when remote is nil", structpath.MustParsePath("git_source"), nil, deployplan.Skip}, + {"source_code_path skips when ActiveDeployment is nil", structpath.MustParsePath("source_code_path"), &AppRemote{}, deployplan.Skip}, + {"source_code_path untouched when ActiveDeployment is set", structpath.MustParsePath("source_code_path"), &AppRemote{App: apps.App{ActiveDeployment: &apps.AppDeployment{}}}, deployplan.Update}, + {"name untouched (not a deploy-only field)", structpath.MustParsePath("name"), nil, deployplan.Update}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + change := &ChangeDesc{Action: deployplan.Update} + require.NoError(t, r.OverrideChangeDesc(t.Context(), tc.path, change, tc.remote)) + assert.Equal(t, tc.wantAction, change.Action) + }) + } +} diff --git a/bundle/direct/dresources/cluster.go b/bundle/direct/dresources/cluster.go index 53d2e6c417f..a6cb28cef43 100644 --- a/bundle/direct/dresources/cluster.go +++ b/bundle/direct/dresources/cluster.go @@ -195,6 +195,12 @@ func (r *ResourceCluster) DoUpdate(ctx context.Context, id string, config *Clust return nil, nil } + // entry.RemoteState is nil in --planmode=offline: we have no live status, + // so skip lifecycle management. WaitAfterUpdate also skips its wait. + if entry.RemoteState == nil { + return nil, nil + } + desiredStarted := *config.Lifecycle.Started alreadyRunning := remoteClusterIsRunning(entry) if desiredStarted && !alreadyRunning { @@ -212,11 +218,17 @@ func (r *ResourceCluster) DoUpdate(ctx context.Context, id string, config *Clust } // WaitAfterUpdate waits for the cluster to reach the desired lifecycle state after DoUpdate. -func (r *ResourceCluster) WaitAfterUpdate(ctx context.Context, id string, config *ClusterState) (*ClusterRemote, error) { +// In --planmode=offline, entry.RemoteState is nil and DoUpdate skipped the Start/Stop call +// (see manageLifecycle), so there's no lifecycle transition to wait for. +func (r *ResourceCluster) WaitAfterUpdate(ctx context.Context, id string, config *ClusterState, entry *PlanEntry) (*ClusterRemote, error) { if config.Lifecycle == nil || config.Lifecycle.Started == nil { return nil, nil } + if entry.RemoteState == nil { + return nil, nil + } + if *config.Lifecycle.Started { _, err := r.client.Clusters.WaitGetClusterRunning(ctx, id, clusterWaitTimeout, nil) return nil, err diff --git a/bundle/direct/dresources/grants.go b/bundle/direct/dresources/grants.go index bac70bc5615..69160824297 100644 --- a/bundle/direct/dresources/grants.go +++ b/bundle/direct/dresources/grants.go @@ -165,13 +165,22 @@ func buildGrantChanges(desiredAssignments []catalog.PrivilegeAssignment, removed return changes } -// removedGrantPrincipals returns principals present in the remote state but absent from the desired assignments. +// removedGrantPrincipals returns principals present in the prior remote/saved +// state but absent from the desired assignments. Grants has StateType == +// RemoteType == *GrantsState, so RemoteState (fresh remote, full mode) and +// PriorState (saved state, populated in offline mode) are directly interchangeable: +// we prefer the fresh remote when we have it, and fall back to saved state. +// This makes --planmode=offline correctly remove principals that config no +// longer declares, using the last-recorded set as the removal baseline. func removedGrantPrincipals(desiredAssignments []catalog.PrivilegeAssignment, entry *PlanEntry) []string { if entry == nil { return nil } - remote, ok := entry.RemoteState.(*GrantsState) - if !ok || remote == nil { + prior, _ := entry.RemoteState.(*GrantsState) + if prior == nil { + prior, _ = entry.PriorState.(*GrantsState) + } + if prior == nil { return nil } @@ -183,7 +192,7 @@ func removedGrantPrincipals(desiredAssignments []catalog.PrivilegeAssignment, en } var result []string - for _, a := range remote.EmbeddedSlice { + for _, a := range prior.EmbeddedSlice { if _, ok := desired[a.Principal]; !ok { result = append(result, a.Principal) } diff --git a/bundle/direct/dresources/model.go b/bundle/direct/dresources/model.go index ad8a9cca5a3..4d234f3baa2 100644 --- a/bundle/direct/dresources/model.go +++ b/bundle/direct/dresources/model.go @@ -92,10 +92,14 @@ func (r *ResourceMlflowModel) DoUpdate(ctx context.Context, id string, config *m return nil, errors.New("UpdateModel returned no registered_model") } - // Carry forward model_id from existing state since UpdateModelResponse doesn't include it. - var modelId string - if old, ok := entry.RemoteState.(*MlflowModelRemote); ok { - modelId = old.ModelId + // UpdateModelResponse doesn't include model_id. Normally we carry it forward + // from entry.RemoteState (populated by the plan-time DoRead). In + // --planmode=offline there was no plan-time read, so return nil here; the + // engine's refreshRemoteState will then DoRead and populate the cache with + // the correct model_id for post-update reference resolution. + old, _ := entry.RemoteState.(*MlflowModelRemote) + if old == nil { + return nil, nil } // Id and PermissionLevel are left empty because ml.Model (the UpdateModel @@ -115,7 +119,7 @@ func (r *ResourceMlflowModel) DoUpdate(ctx context.Context, id string, config *m UserId: response.RegisteredModel.UserId, ForceSendFields: utils.FilterFields[ml.ModelDatabricks](response.RegisteredModel.ForceSendFields, "Id", "PermissionLevel"), }, - ModelId: modelId, + ModelId: old.ModelId, }, nil } diff --git a/bundle/direct/dresources/model_serving_endpoint.go b/bundle/direct/dresources/model_serving_endpoint.go index e8a1917c2f2..9509d280ed1 100644 --- a/bundle/direct/dresources/model_serving_endpoint.go +++ b/bundle/direct/dresources/model_serving_endpoint.go @@ -173,7 +173,7 @@ func (r *ResourceModelServingEndpoint) WaitAfterCreate(ctx context.Context, id s return r.waitForEndpointReady(ctx, config.Name) } -func (r *ResourceModelServingEndpoint) WaitAfterUpdate(ctx context.Context, id string, config *serving.CreateServingEndpoint) (*ModelServingEndpointRemote, error) { +func (r *ResourceModelServingEndpoint) WaitAfterUpdate(ctx context.Context, id string, config *serving.CreateServingEndpoint, _ *PlanEntry) (*ModelServingEndpointRemote, error) { return r.waitForEndpointReady(ctx, config.Name) } diff --git a/bundle/direct/dresources/sql_warehouse.go b/bundle/direct/dresources/sql_warehouse.go index 4854e5ed1fb..2cda9acb8ef 100644 --- a/bundle/direct/dresources/sql_warehouse.go +++ b/bundle/direct/dresources/sql_warehouse.go @@ -168,6 +168,12 @@ func (r *ResourceSqlWarehouse) DoUpdate(ctx context.Context, id string, config * return nil, nil } + // entry.RemoteState is nil in --planmode=offline: we have no live status, + // so skip lifecycle management. WaitAfterUpdate also skips its wait. + if entry.RemoteState == nil { + return nil, nil + } + desiredStarted := *config.Lifecycle.Started alreadyRunning := remoteWarehouseIsRunning(entry) if edited { @@ -190,11 +196,17 @@ func (r *ResourceSqlWarehouse) DoUpdate(ctx context.Context, id string, config * } // WaitAfterUpdate waits for the warehouse to reach the desired lifecycle state after DoUpdate. -func (r *ResourceSqlWarehouse) WaitAfterUpdate(ctx context.Context, id string, config *SqlWarehouseState) (*SqlWarehouseRemote, error) { +// In --planmode=offline, entry.RemoteState is nil and DoUpdate skipped the Start/Stop call, +// so there's no lifecycle transition to wait for. +func (r *ResourceSqlWarehouse) WaitAfterUpdate(ctx context.Context, id string, config *SqlWarehouseState, entry *PlanEntry) (*SqlWarehouseRemote, error) { if config.Lifecycle == nil || config.Lifecycle.Started == nil { return nil, nil } + if entry.RemoteState == nil { + return nil, nil + } + if *config.Lifecycle.Started { _, err := r.client.Warehouses.WaitGetWarehouseRunning(ctx, id, 20*time.Minute, nil) return nil, err diff --git a/bundle/direct/dresources/vector_search_index.go b/bundle/direct/dresources/vector_search_index.go index 48ee6f0f968..75ba3e0fced 100644 --- a/bundle/direct/dresources/vector_search_index.go +++ b/bundle/direct/dresources/vector_search_index.go @@ -204,15 +204,16 @@ func (r *ResourceVectorSearchIndex) WaitAfterDelete(ctx context.Context, id stri // lookupEndpointUuid distinguishes this (404 -> "") from transient errors // (propagated through DoRead/DoCreate), so reaching this branch with empty // remoteUuid unambiguously means the endpoint is gone. -func (*ResourceVectorSearchIndex) OverrideChangeDesc(_ context.Context, path *structpath.PathNode, change *ChangeDesc, remote *VectorSearchIndexRemote) error { +func (*ResourceVectorSearchIndex) OverrideChangeDesc(_ context.Context, path *structpath.PathNode, change *ChangeDesc, _ *VectorSearchIndexRemote) error { if path.String() != "endpoint_uuid" { return nil } savedUuid, _ := change.Old.(string) - var remoteUuid string - if remote != nil { - remoteUuid = remote.EndpointUuid - } + // change.Remote is endpoint_uuid from the remapped remote state (equal to the + // raw remote's EndpointUuid), or the saved value in --local mode. Reading it + // rather than the raw remote param keeps this correct when --local supplies no + // remote read. + remoteUuid, _ := change.Remote.(string) if savedUuid != "" && savedUuid != remoteUuid { change.Action = deployplan.Recreate change.Reason = "endpoint replaced out-of-band" diff --git a/bundle/metrics/metrics.go b/bundle/metrics/metrics.go index 002adea1ddf..c70f01350f9 100644 --- a/bundle/metrics/metrics.go +++ b/bundle/metrics/metrics.go @@ -11,6 +11,11 @@ const ( SqlWarehouseLifecycleStarted = "sql_warehouse_lifecycle_started" SelectUsed = "select_used" + // PlanModeUsed is a metric-key prefix; the mode value is appended per deploy, + // producing metric keys like plan_mode_used_offline. Only recorded when the + // deploy used a non-default plan mode. + PlanModeUsed = "plan_mode_used" + // Outcome of the dry-run migration to the direct engine attempted after a // successful terraform deploy WHEN THE USER DID NOT OPT IN. Only recorded // when the state conversion is truly a dry run (no auto-migrate). diff --git a/bundle/phases/deploy.go b/bundle/phases/deploy.go index f65e50a940e..515d3e8880c 100644 --- a/bundle/phases/deploy.go +++ b/bundle/phases/deploy.go @@ -270,7 +270,7 @@ func Deploy(ctx context.Context, b *bundle.Bundle, outputHandler sync.OutputHand func RunPlan(ctx context.Context, b *bundle.Bundle, engine engine.EngineType) *deployplan.Plan { if engine.IsDirect() { - plan, err := b.DeploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), &b.Config) + plan, err := b.DeploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), &b.Config, b.PlanMode) if err != nil { logdiag.LogError(ctx, err) return nil diff --git a/bundle/phases/destroy.go b/bundle/phases/destroy.go index 2496c7033ad..9ec41e8e324 100644 --- a/bundle/phases/destroy.go +++ b/bundle/phases/destroy.go @@ -155,7 +155,7 @@ func Destroy(ctx context.Context, b *bundle.Bundle, engine engine.EngineType) { var plan *deployplan.Plan if engine.IsDirect() { - plan, err = b.DeploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), nil) + plan, err = b.DeploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), nil, deployplan.PlanModeFull) if err != nil { logdiag.LogError(ctx, err) return diff --git a/cmd/bundle/config_remote_sync.go b/cmd/bundle/config_remote_sync.go index e5231fef58a..9fe1e35b146 100644 --- a/cmd/bundle/config_remote_sync.go +++ b/cmd/bundle/config_remote_sync.go @@ -9,6 +9,7 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/configsync" + "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/statemgmt" "github.com/databricks/cli/cmd/bundle/utils" "github.com/databricks/cli/cmd/root" @@ -87,7 +88,7 @@ Examples: return err } - plan, err := deployBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), &b.Config) + plan, err := deployBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), &b.Config, deployplan.PlanModeFull) if err != nil { stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryDetectChangesFailed return fmt.Errorf("failed to detect changes: %w", err) diff --git a/cmd/bundle/deploy.go b/cmd/bundle/deploy.go index 95776eb25f4..3ce17963614 100644 --- a/cmd/bundle/deploy.go +++ b/cmd/bundle/deploy.go @@ -4,6 +4,7 @@ package bundle import ( "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/cmd/bundle/utils" "github.com/databricks/cli/cmd/root" "github.com/spf13/cobra" @@ -32,6 +33,7 @@ See https://docs.databricks.com/en/dev-tools/bundles/index.html for more informa var verbose bool var readPlanPath string var selectResources []string + var planMode string cmd.Flags().BoolVar(&force, "force", false, "Force-override Git branch validation.") cmd.Flags().BoolVar(&forceLock, "force-lock", false, "Force acquisition of deployment lock.") cmd.Flags().BoolVar(&failOnActiveRuns, "fail-on-active-runs", false, "Fail if there are running jobs or pipelines in the deployment.") @@ -42,16 +44,23 @@ See https://docs.databricks.com/en/dev-tools/bundles/index.html for more informa cmd.Flags().BoolVar(&verbose, "verbose", false, "Enable verbose output.") cmd.Flags().StringVar(&readPlanPath, "plan", "", "Path to a JSON plan file to apply instead of planning (direct engine only).") cmd.Flags().StringSliceVar(&selectResources, "select", nil, "Deploy only the specified resource (e.g. 'my_job' or 'jobs.my_job'). Can be repeated or comma-separated.") + cmd.Flags().StringVar(&planMode, "planmode", "", "How much of the remote state to consult when planning the deploy: full (default) or offline.") + cmd.Flags().MarkHidden("planmode") // Verbose flag currently only affects file sync output, it's used by the vscode extension cmd.Flags().MarkHidden("verbose") cmd.RunE = func(cmd *cobra.Command, args []string) error { - _, err := utils.ProcessBundle(cmd, utils.ProcessOptions{ + mode, err := deployplan.ParsePlanMode(planMode) + if err != nil { + return err + } + _, err = utils.ProcessBundle(cmd, utils.ProcessOptions{ InitFunc: func(b *bundle.Bundle) { b.Config.Bundle.Force = force b.Config.Bundle.Deployment.Lock.Force = forceLock b.AutoApprove = autoApprove b.Select = selectResources + b.PlanMode = mode if cmd.Flag("compute-id").Changed { b.Config.Bundle.ClusterId = clusterId diff --git a/cmd/bundle/plan.go b/cmd/bundle/plan.go index 20df8cb5f0f..8692acd5ab4 100644 --- a/cmd/bundle/plan.go +++ b/cmd/bundle/plan.go @@ -29,13 +29,20 @@ It is useful for previewing changes before running 'bundle deploy'.`, var force bool var clusterId string var selectResources []string + var planMode string cmd.Flags().BoolVar(&force, "force", false, "Force-override Git branch validation.") cmd.Flags().StringVar(&clusterId, "compute-id", "", "Override cluster in the deployment with the given compute ID.") cmd.Flags().StringVarP(&clusterId, "cluster-id", "c", "", "Override cluster in the deployment with the given cluster ID.") cmd.Flags().MarkDeprecated("compute-id", "use --cluster-id instead") cmd.Flags().StringSliceVar(&selectResources, "select", nil, "Plan only the specified resource (e.g. 'my_job' or 'jobs.my_job'). Can be repeated or comma-separated.") + cmd.Flags().StringVar(&planMode, "planmode", "", "How much of the remote state to consult during plan: full (default) or offline.") + cmd.Flags().MarkHidden("planmode") cmd.RunE = func(cmd *cobra.Command, args []string) error { + mode, err := deployplan.ParsePlanMode(planMode) + if err != nil { + return err + } opts := utils.ProcessOptions{ AlwaysPull: true, FastValidate: true, @@ -44,6 +51,7 @@ It is useful for previewing changes before running 'bundle deploy'.`, InitFunc: func(b *bundle.Bundle) { b.Config.Bundle.Force = force b.Select = selectResources + b.PlanMode = mode if cmd.Flag("compute-id").Changed { b.Config.Bundle.ClusterId = clusterId diff --git a/cmd/bundle/utils/process.go b/cmd/bundle/utils/process.go index e4f232605ce..6f5459f7ce2 100644 --- a/cmd/bundle/utils/process.go +++ b/cmd/bundle/utils/process.go @@ -15,6 +15,7 @@ import ( "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/direct" "github.com/databricks/cli/bundle/direct/dstate" + "github.com/databricks/cli/bundle/metrics" "github.com/databricks/cli/bundle/phases" "github.com/databricks/cli/bundle/statemgmt" "github.com/databricks/cli/cmd/root" @@ -207,6 +208,15 @@ func ProcessBundleRet(cmd *cobra.Command, opts ProcessOptions) (b *bundle.Bundle return b, stateDesc, root.ErrAlreadyPrinted } + // --planmode=offline skips the per-resource remote read; only the direct engine performs it. + if b.PlanMode == deployplan.PlanModeOffline { + if !stateDesc.Engine.IsDirect() { + logdiag.LogError(ctx, errors.New("--planmode=offline is only supported with the direct engine. See https://docs.databricks.com/aws/en/dev-tools/bundles/direct")) + return b, stateDesc, root.ErrAlreadyPrinted + } + b.Metrics.SetBoolValue(metrics.PlanModeUsed+"_offline", true) + } + // Open direct engine state once for all subsequent operations (ExportState, CalculatePlan, Apply, etc.) needDirectState := stateDesc.Engine.IsDirect() && (opts.InitIDs || opts.ErrorOnEmptyState || opts.Deploy || opts.ReadPlanPath != "" || opts.PreDeployChecks || opts.PostStateFunc != nil) if needDirectState { @@ -268,6 +278,9 @@ func ProcessBundleRet(cmd *cobra.Command, opts ProcessOptions) (b *bundle.Bundle if plan.CLIVersion != currentVersion { log.Warnf(ctx, "Plan was created with CLI version %s but current version is %s", plan.CLIVersion, currentVersion) } + if plan.Mode == deployplan.PlanModeOffline { + log.Warnf(ctx, "Plan was created with --planmode=offline and does not reflect the remote state of resources; applying it may miss out-of-band drift") + } // Validate that the plan's lineage and serial match the current state // This must happen before any file operations