From 01a4306e49a8b951a5f172cd753295b34ecd7d60 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Tue, 15 Sep 2026 21:42:29 +0000 Subject: [PATCH 1/2] Print destroy summary even on error Make the destroy summary line ("Destroy: N deleted") print even when the destroy encounters errors partway through. This is a follow-up to PR #6210. Before this change, early returns on errors (during the resource Apply, DMS finalization, or file deletion) skipped the summary entirely. Extract the summary into logDestroySummary and invoke it via defer in destroyCore so it prints on every return path; partial deletions may have already succeeded. The count is the planned deletions, not the actually-succeeded ones. Making the count reflect only successful deletions is a separate follow-up. Add an acceptance test that injects a failed resource delete and asserts the summary still prints after the destroy error. The dms/failed-delete golden gains the same summary line for the same reason. Co-authored-by: Isaac --- .../bundles/print-destroy-summary-on-error.md | 1 + .../destroy/summary-on-error/databricks.yml | 16 ++++++++ .../destroy/summary-on-error/out.test.toml | 2 + .../destroy/summary-on-error/output.txt | 29 ++++++++++++++ .../bundle/destroy/summary-on-error/script | 13 +++++++ .../bundle/destroy/summary-on-error/test.toml | 8 ++++ .../bundle/dms/failed-delete/output.txt | 1 + bundle/phases/destroy.go | 38 ++++++++++--------- 8 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 .nextchanges/bundles/print-destroy-summary-on-error.md create mode 100644 acceptance/bundle/destroy/summary-on-error/databricks.yml create mode 100644 acceptance/bundle/destroy/summary-on-error/out.test.toml create mode 100644 acceptance/bundle/destroy/summary-on-error/output.txt create mode 100644 acceptance/bundle/destroy/summary-on-error/script create mode 100644 acceptance/bundle/destroy/summary-on-error/test.toml diff --git a/.nextchanges/bundles/print-destroy-summary-on-error.md b/.nextchanges/bundles/print-destroy-summary-on-error.md new file mode 100644 index 00000000000..3516b49bad0 --- /dev/null +++ b/.nextchanges/bundles/print-destroy-summary-on-error.md @@ -0,0 +1 @@ +* Bundle destroy now prints the deletion summary even when destroy encounters errors, since partial deletions may have already succeeded. diff --git a/acceptance/bundle/destroy/summary-on-error/databricks.yml b/acceptance/bundle/destroy/summary-on-error/databricks.yml new file mode 100644 index 00000000000..f64d5b07d3c --- /dev/null +++ b/acceptance/bundle/destroy/summary-on-error/databricks.yml @@ -0,0 +1,16 @@ +bundle: + name: test-destroy-summary-on-error + +resources: + jobs: + # Chained job_01 <- job_02 <- job_03 via id references, so destroy deletes them in a + # fixed order (children first: job_03, job_02, job_01). Independent jobs would delete + # in parallel, making both the faulted delete and the error output vary per run. + job_01: + name: job-01 + job_02: + name: job-02 + description: needs ${resources.jobs.job_01.id} + job_03: + name: job-03 + description: needs ${resources.jobs.job_02.id} diff --git a/acceptance/bundle/destroy/summary-on-error/out.test.toml b/acceptance/bundle/destroy/summary-on-error/out.test.toml new file mode 100644 index 00000000000..88e6fbd847c --- /dev/null +++ b/acceptance/bundle/destroy/summary-on-error/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["true"] diff --git a/acceptance/bundle/destroy/summary-on-error/output.txt b/acceptance/bundle/destroy/summary-on-error/output.txt new file mode 100644 index 00000000000..cddfc463df4 --- /dev/null +++ b/acceptance/bundle/destroy/summary-on-error/output.txt @@ -0,0 +1,29 @@ + +=== Deploy three chained jobs +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-destroy-summary-on-error/default/files... +Created jobs.job_01 +Created jobs.job_02 +Created jobs.job_03 +Files: 1 uploaded, 0 deleted +Resources: 3 created, 0 changed, 0 deleted, 0 unchanged + +=== Destroy: only one delete fails, yet the summary counts every planned deletion +>>> fault.py POST /api/2.2/jobs/delete 400 2 1 INVALID_PARAMETER_VALUE + +>>> musterr [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.jobs.job_01 + delete resources.jobs.job_02 + delete resources.jobs.job_03 + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/test-destroy-summary-on-error/default + +Error: cannot delete resources.jobs.job_01: deleting id=[JOB_01_ID]: Fault injected by test. (400 INVALID_PARAMETER_VALUE) + +Endpoint: POST [DATABRICKS_URL]/api/2.2/jobs/delete +HTTP Status: 400 Bad Request +API error_code: INVALID_PARAMETER_VALUE +API message: Fault injected by test. + +Destroy: 3 deleted diff --git a/acceptance/bundle/destroy/summary-on-error/script b/acceptance/bundle/destroy/summary-on-error/script new file mode 100644 index 00000000000..11c43d729f4 --- /dev/null +++ b/acceptance/bundle/destroy/summary-on-error/script @@ -0,0 +1,13 @@ +title "Deploy three chained jobs" +trace $CLI bundle deploy +# Mask the job IDs (the destroy error below prints job_01's) so the golden is stable. +replace_ids.py + +title "Destroy: only one delete fails, yet the summary counts every planned deletion" +# Deletes run children-first (job_03, job_02, job_01); faulting the third makes only +# job_01 fail while job_03 and job_02 are really deleted. logDestroySummary runs from a +# defer, so the summary still prints on the error return (without the defer the error +# would skip it). The count is the planned deletions, not the ones that succeeded, so +# job_01 is reported as deleted although it was not. An accurate partial count is a follow-up. +trace fault.py "POST /api/2.2/jobs/delete" 400 2 1 INVALID_PARAMETER_VALUE +trace musterr $CLI bundle destroy --auto-approve diff --git a/acceptance/bundle/destroy/summary-on-error/test.toml b/acceptance/bundle/destroy/summary-on-error/test.toml new file mode 100644 index 00000000000..36831ad4d80 --- /dev/null +++ b/acceptance/bundle/destroy/summary-on-error/test.toml @@ -0,0 +1,8 @@ +# fault.py registers its rule on the fake server, which a real workspace does not have. +Cloud = false + +# A failed resource delete only surfaces as a destroy error under the DMS (deployment +# history) engine, so pin that variant to exercise the error path deterministically. +EnvMatrix.DMS = ["true"] + +Ignore = [".databricks"] diff --git a/acceptance/bundle/dms/failed-delete/output.txt b/acceptance/bundle/dms/failed-delete/output.txt index fd8d9a72e61..c3c321796aa 100644 --- a/acceptance/bundle/dms/failed-delete/output.txt +++ b/acceptance/bundle/dms/failed-delete/output.txt @@ -112,6 +112,7 @@ HTTP Status: 400 Bad Request API error_code: INVALID_PARAMETER_VALUE API message: Fault injected by test. +Destroy: 1 deleted >>> print_requests.py --dms //api/2.0/bundle { diff --git a/bundle/phases/destroy.go b/bundle/phases/destroy.go index 8b5497d626a..f8445476b7a 100644 --- a/bundle/phases/destroy.go +++ b/bundle/phases/destroy.go @@ -138,7 +138,28 @@ func approvalForDestroy(ctx context.Context, b *bundle.Bundle, plan *deployplan. return cmdio.AskYesOrNo(ctx, "Would you like to proceed?") } +// logDestroySummary prints the destroy summary showing how many resources were deleted. +// This is called even when destroy errors occur, since partial deletions may have succeeded. +func logDestroySummary(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan) { + if b.Quiet < bundle.QuietAll { + // Count top-level resources only, matching the approval list above (which + // skips children). Gone resources are excluded to match that list: they were + // already deleted remotely, so applying their Delete only cleans up stale + // state and is not a destruction to report. + deleted := 0 + for _, a := range plan.GetActions() { + if a.ActionType == deployplan.Delete && !a.IsChildResource() && !a.IsStateOnlyDelete() { + deleted++ + } + } + cmdio.LogString(ctx, fmt.Sprintf("Destroy: %d deleted", deleted)) + } +} + func destroyCore(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan) { + // Ensure the destroy summary is printed even on error, since partial deletions + // may have succeeded (an accurate partial count is a follow-up). + defer logDestroySummary(ctx, b, plan) // Not reported per resource: destroy names them up front for consent and then // reports only a count, so there is no per-resource output to report into. b.DeploymentBundle.Apply(ctx, b.WorkspaceClient(ctx), plan, false) @@ -177,23 +198,6 @@ func destroyCore(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan) { bundle.ApplyContext(ctx, b, files.Delete()) - // Print the summary even on error: resources that were deleted are still worth - // reporting (an accurate partial count is a follow-up). - if b.Quiet < bundle.QuietAll { - // Count top-level resources only, matching the approval list above (which - // skips children); this also keeps the count stable across engines. Gone - // resources are excluded to match that list: they were already deleted - // remotely, so applying their Delete only cleans up stale state and is not - // a destruction to report. - deleted := 0 - for _, a := range plan.GetActions() { - if a.ActionType == deployplan.Delete && !a.IsChildResource() && !a.IsStateOnlyDelete() { - deleted++ - } - } - cmdio.LogString(ctx, fmt.Sprintf("Destroy: %d deleted", deleted)) - } - if logdiag.HasError(ctx) { return } From 9e7f1c7cf651bbb6a63bbfedb1695b81a72a8d0e Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 9 Oct 2026 12:48:26 +0000 Subject: [PATCH 2/2] Report accurate resource counts in bundle summaries The deploy and destroy summaries counted planned operations, so a resource that failed to apply was still reported as created/deleted. Track per-resource outcomes during Apply - Attempted when the graph reaches a node, Applied when its backend operation succeeds - and count what actually happened: Destroy: 2 deleted, 1 failed Resources: 1 created, 0 changed, 0 deleted, 0 unchanged, 2 failed The deploy summary now also prints on a partial resource failure (it was suppressed before). It stays suppressed when no resource failed: a failure before the apply graph runs (e.g. a config error) or after it (e.g. a state-push error) still reports files only, as before. Direct engine only; the terraform engine was removed in v1.20.0. Co-authored-by: Isaac --- .../accurate-resource-summary-counts.md | 1 + .../deploy/summary-on-error/databricks.yml | 16 +++++ .../deploy/summary-on-error/out.test.toml | 2 + .../bundle/deploy/summary-on-error/output.txt | 18 +++++ .../bundle/deploy/summary-on-error/script | 6 ++ .../bundle/deploy/summary-on-error/test.toml | 4 ++ .../destroy/summary-on-error/output.txt | 2 +- .../bundle/dms/failed-delete/output.txt | 2 +- .../bundle/dms/failed-recreate/output.txt | 1 + .../dms/failed-update-permissions/output.txt | 1 + .../bundle/dms/record-failure/output.txt | 1 + .../migrate/auto/apply-failure/output.txt | 1 + .../resource_deps/create_error/out.deploy.txt | 1 + .../resource_deps/create_error/output.txt | 9 ++- .../bundle/resource_deps/create_error/script | 4 +- .../out.deploy.txt | 1 + .../missing_map_key/out.deploy.txt | 1 + .../apps/create_already_exists/output.txt | 1 + .../resources/catalogs/empty-name/output.txt | 1 + .../definition_and_family_conflict/output.txt | 1 + .../out.deploy.txt | 1 + .../publish-failure-stale-content/output.txt | 1 + .../destroy_unfinished_run/output.txt | 1 + .../resources/job_runs/failed_run/output.txt | 2 + .../job_runs/interrupted_run/output.txt | 1 + .../config_conflicts_policy/output.txt | 1 + .../resources/jobs/create-error/output.txt | 2 + .../jobs/no-git-provider/out.deploy.txt | 1 + .../models/empty-name/out.deploy.txt | 1 + .../without_branch_id/out.deploy.txt | 1 + .../live_errors/bad_database_id/output.txt | 1 + .../live_errors/bad_role_ref/output.txt | 1 + .../remove_suspend_timeout/out.deploy.txt | 1 + .../without_endpoint_id/out.deploy.txt | 1 + .../out.deploy.txt | 1 + .../remove_history_retention/out.deploy.txt | 1 + .../without_project_id/out.deploy.txt | 1 + .../inherited-role-conflict/output.txt | 1 + .../recreate_capped/output.txt | 1 + .../recreate/create-fails/output.txt | 1 + .../set-storage-location/out.deploy.txt | 1 + bundle/deployplan/action.go | 4 ++ bundle/deployplan/plan.go | 65 +++++++++++++++++++ bundle/deployplan/plan_test.go | 65 +++++++++++++++++++ bundle/direct/bundle_apply.go | 12 +++- bundle/phases/deploy.go | 31 +++++---- bundle/phases/destroy.go | 23 +++++-- 47 files changed, 272 insertions(+), 25 deletions(-) create mode 100644 .nextchanges/bundles/accurate-resource-summary-counts.md create mode 100644 acceptance/bundle/deploy/summary-on-error/databricks.yml create mode 100644 acceptance/bundle/deploy/summary-on-error/out.test.toml create mode 100644 acceptance/bundle/deploy/summary-on-error/output.txt create mode 100644 acceptance/bundle/deploy/summary-on-error/script create mode 100644 acceptance/bundle/deploy/summary-on-error/test.toml diff --git a/.nextchanges/bundles/accurate-resource-summary-counts.md b/.nextchanges/bundles/accurate-resource-summary-counts.md new file mode 100644 index 00000000000..69fcc696072 --- /dev/null +++ b/.nextchanges/bundles/accurate-resource-summary-counts.md @@ -0,0 +1 @@ +* Bundle `deploy` and `destroy` summaries now report how many resources actually succeeded and, on a partial failure, how many failed, instead of the planned count. For example, if one of three resources fails to delete, the summary now reads `Destroy: 2 deleted, 1 failed` rather than `Destroy: 3 deleted`. diff --git a/acceptance/bundle/deploy/summary-on-error/databricks.yml b/acceptance/bundle/deploy/summary-on-error/databricks.yml new file mode 100644 index 00000000000..e82903c6722 --- /dev/null +++ b/acceptance/bundle/deploy/summary-on-error/databricks.yml @@ -0,0 +1,16 @@ +bundle: + name: test-deploy-summary-on-error + +resources: + jobs: + # Chained job_01 <- job_02 <- job_03 via id references, so deploy creates them in a + # fixed order (job_01, job_02, job_03). Independent jobs would create in parallel, + # making both the faulted create and the error output vary per run. + job_01: + name: job-01 + job_02: + name: job-02 + description: needs ${resources.jobs.job_01.id} + job_03: + name: job-03 + description: needs ${resources.jobs.job_02.id} diff --git a/acceptance/bundle/deploy/summary-on-error/out.test.toml b/acceptance/bundle/deploy/summary-on-error/out.test.toml new file mode 100644 index 00000000000..a927a5fbc06 --- /dev/null +++ b/acceptance/bundle/deploy/summary-on-error/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["", "true"] diff --git a/acceptance/bundle/deploy/summary-on-error/output.txt b/acceptance/bundle/deploy/summary-on-error/output.txt new file mode 100644 index 00000000000..32dea28bf6a --- /dev/null +++ b/acceptance/bundle/deploy/summary-on-error/output.txt @@ -0,0 +1,18 @@ + +=== Deploy three chained jobs; the second create fails +>>> fault.py POST /api/2.2/jobs/create 400 1 1 INVALID_PARAMETER_VALUE + +>>> musterr [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-deploy-summary-on-error/default/files... +Created jobs.job_01 +Error: cannot create resources.jobs.job_02: Fault injected by test. (400 INVALID_PARAMETER_VALUE) + +Endpoint: POST [DATABRICKS_URL]/api/2.2/jobs/create +HTTP Status: 400 Bad Request +API error_code: INVALID_PARAMETER_VALUE +API message: Fault injected by test. + +Error: cannot create resources.jobs.job_03: dependency failed: resources.jobs.job_02 + +Files: 1 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged, 2 failed diff --git a/acceptance/bundle/deploy/summary-on-error/script b/acceptance/bundle/deploy/summary-on-error/script new file mode 100644 index 00000000000..6ce82e64857 --- /dev/null +++ b/acceptance/bundle/deploy/summary-on-error/script @@ -0,0 +1,6 @@ +title "Deploy three chained jobs; the second create fails" +# Creates run in chain order (job_01, job_02, job_03); faulting the second create makes +# job_01 succeed, job_02 fail, and job_03 skip because its dependency failed. The summary +# reports the accurate counts (1 created, 2 failed) rather than the planned 3 created. +trace fault.py "POST /api/2.2/jobs/create" 400 1 1 INVALID_PARAMETER_VALUE +trace musterr $CLI bundle deploy diff --git a/acceptance/bundle/deploy/summary-on-error/test.toml b/acceptance/bundle/deploy/summary-on-error/test.toml new file mode 100644 index 00000000000..2b0b3a6ccb0 --- /dev/null +++ b/acceptance/bundle/deploy/summary-on-error/test.toml @@ -0,0 +1,4 @@ +# fault.py registers its rule on the fake server, which a real workspace does not have. +Cloud = false + +Ignore = [".databricks"] diff --git a/acceptance/bundle/destroy/summary-on-error/output.txt b/acceptance/bundle/destroy/summary-on-error/output.txt index cddfc463df4..d8afafbdf91 100644 --- a/acceptance/bundle/destroy/summary-on-error/output.txt +++ b/acceptance/bundle/destroy/summary-on-error/output.txt @@ -26,4 +26,4 @@ HTTP Status: 400 Bad Request API error_code: INVALID_PARAMETER_VALUE API message: Fault injected by test. -Destroy: 3 deleted +Destroy: 2 deleted, 1 failed diff --git a/acceptance/bundle/dms/failed-delete/output.txt b/acceptance/bundle/dms/failed-delete/output.txt index c3c321796aa..bcb92b24917 100644 --- a/acceptance/bundle/dms/failed-delete/output.txt +++ b/acceptance/bundle/dms/failed-delete/output.txt @@ -112,7 +112,7 @@ HTTP Status: 400 Bad Request API error_code: INVALID_PARAMETER_VALUE API message: Fault injected by test. -Destroy: 1 deleted +Destroy: 0 deleted, 1 failed >>> print_requests.py --dms //api/2.0/bundle { diff --git a/acceptance/bundle/dms/failed-recreate/output.txt b/acceptance/bundle/dms/failed-recreate/output.txt index ddbd5707b6c..9376b3183cc 100644 --- a/acceptance/bundle/dms/failed-recreate/output.txt +++ b/acceptance/bundle/dms/failed-recreate/output.txt @@ -114,6 +114,7 @@ API error_code: INVALID_PARAMETER_VALUE API message: Fault injected by test. Files: 2 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed >>> print_requests.py --dms //api/2.0/bundle { diff --git a/acceptance/bundle/dms/failed-update-permissions/output.txt b/acceptance/bundle/dms/failed-update-permissions/output.txt index c73181e7247..b74b2f442e8 100644 --- a/acceptance/bundle/dms/failed-update-permissions/output.txt +++ b/acceptance/bundle/dms/failed-update-permissions/output.txt @@ -64,6 +64,7 @@ API error_code: PERMISSION_DENIED API message: Fault injected by test. Files: 1 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 1 unchanged, 1 failed === The service lists the job with the failure >>> MSYS_NO_PATHCONV=1 [CLI] bundle-deployments list-resources deployments/[DEPLOYMENT_ID] -o json diff --git a/acceptance/bundle/dms/record-failure/output.txt b/acceptance/bundle/dms/record-failure/output.txt index a35bb951a32..fd3996ad47d 100644 --- a/acceptance/bundle/dms/record-failure/output.txt +++ b/acceptance/bundle/dms/record-failure/output.txt @@ -10,6 +10,7 @@ API error_code: INVALID_PARAMETER_VALUE API message: Node type nonexistent-node-type is not supported. Supported node types: [NODE_TYPES] Files: 2 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed >>> [CLI] bundle plan -o json { diff --git a/acceptance/bundle/migrate/auto/apply-failure/output.txt b/acceptance/bundle/migrate/auto/apply-failure/output.txt index be5d6f0a284..a1ce725db10 100644 --- a/acceptance/bundle/migrate/auto/apply-failure/output.txt +++ b/acceptance/bundle/migrate/auto/apply-failure/output.txt @@ -17,6 +17,7 @@ API error_code: INJECTED API message: Fault injected by test. Files: 1 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed Exit code: 1 diff --git a/acceptance/bundle/resource_deps/create_error/out.deploy.txt b/acceptance/bundle/resource_deps/create_error/out.deploy.txt index 6dedb23b1e7..d921a408d9c 100644 --- a/acceptance/bundle/resource_deps/create_error/out.deploy.txt +++ b/acceptance/bundle/resource_deps/create_error/out.deploy.txt @@ -11,3 +11,4 @@ Error: cannot create resources.jobs.bar: dependency failed: resources.jobs.foo Error: cannot create resources.jobs.baz: dependency failed: resources.jobs.foo Files: 0 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged, 3 failed diff --git a/acceptance/bundle/resource_deps/create_error/output.txt b/acceptance/bundle/resource_deps/create_error/output.txt index 9c44fd808d2..66a87cb34c0 100644 --- a/acceptance/bundle/resource_deps/create_error/output.txt +++ b/acceptance/bundle/resource_deps/create_error/output.txt @@ -74,7 +74,14 @@ create jobs.foo Plan: 3 to add, 0 to change, 0 to delete, 1 unchanged -=== Expecting no difference in the output between first and second deploy +=== Second deploy: only the summary differs, independent is now unchanged--- out.deploy.txt ++++ out.deploy2.txt +@@ -12,3 +12,3 @@ + + Files: 0 uploaded, 0 deleted +-Resources: 1 created, 0 changed, 0 deleted, 0 unchanged, 3 failed ++Resources: 0 created, 0 changed, 0 deleted, 1 unchanged, 3 failed + >>> print_requests.py --nostamp //jobs { "method": "POST", diff --git a/acceptance/bundle/resource_deps/create_error/script b/acceptance/bundle/resource_deps/create_error/script index 99db55f4494..1fa355e9f7b 100644 --- a/acceptance/bundle/resource_deps/create_error/script +++ b/acceptance/bundle/resource_deps/create_error/script @@ -13,7 +13,9 @@ trace $CLI bundle plan rm "$OUT_REQUESTS" musterr $CLI bundle deploy -q &> out.deploy2.txt -title "Expecting no difference in the output between first and second deploy" +# The only difference is the summary: the first deploy created the independent job (1 +# created), the second finds it unchanged. foo still fails and bar/baz still skip on both. +title "Second deploy: only the summary differs, independent is now unchanged" diff.py out.deploy.txt out.deploy2.txt rm out.deploy2.txt trace print_requests.py --nostamp //jobs diff --git a/acceptance/bundle/resource_deps/missing_ingestion_definition/out.deploy.txt b/acceptance/bundle/resource_deps/missing_ingestion_definition/out.deploy.txt index 6fec7af0183..8f32d6baf91 100644 --- a/acceptance/bundle/resource_deps/missing_ingestion_definition/out.deploy.txt +++ b/acceptance/bundle/resource_deps/missing_ingestion_definition/out.deploy.txt @@ -3,5 +3,6 @@ Created pipelines.foo Error: cannot create resources.pipelines.bar: cannot resolve "${resources.pipelines.foo.ingestion_definition.connection_name}": ingestion_definition: cannot access nil value Files: 4 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged, 1 failed Exit code: 1 diff --git a/acceptance/bundle/resource_deps/missing_map_key/out.deploy.txt b/acceptance/bundle/resource_deps/missing_map_key/out.deploy.txt index e0627d0797e..09fc5b6e5e8 100644 --- a/acceptance/bundle/resource_deps/missing_map_key/out.deploy.txt +++ b/acceptance/bundle/resource_deps/missing_map_key/out.deploy.txt @@ -3,3 +3,4 @@ Created jobs.test Error: cannot create resources.jobs.bar: cannot resolve "${resources.jobs.test.tasks[0].new_cluster.custom_tags.missing_tag}": tasks[0].new_cluster.custom_tags.missing_tag: key "missing_tag" not found in map Files: 4 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged, 1 failed diff --git a/acceptance/bundle/resources/apps/create_already_exists/output.txt b/acceptance/bundle/resources/apps/create_already_exists/output.txt index b8440469192..bac4fcb4426 100644 --- a/acceptance/bundle/resources/apps/create_already_exists/output.txt +++ b/acceptance/bundle/resources/apps/create_already_exists/output.txt @@ -38,6 +38,7 @@ API error_code: RESOURCE_ALREADY_EXISTS API message: An app with the same name already exists: test-app-already-exists Files: 2 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed >>> [CLI] apps delete test-app-already-exists { diff --git a/acceptance/bundle/resources/catalogs/empty-name/output.txt b/acceptance/bundle/resources/catalogs/empty-name/output.txt index 0dc766af3db..2c1d53875a5 100644 --- a/acceptance/bundle/resources/catalogs/empty-name/output.txt +++ b/acceptance/bundle/resources/catalogs/empty-name/output.txt @@ -9,3 +9,4 @@ API error_code: INVALID_PARAMETER_VALUE API message: Invalid input: RPC CreateCatalog Field managedcatalog.CatalogInfo.name: name "" is not a valid name. Valid names cannot contain spaces, periods, forward slashes, or control characters. Files: 2 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed diff --git a/acceptance/bundle/resources/cluster_policies/definition_and_family_conflict/output.txt b/acceptance/bundle/resources/cluster_policies/definition_and_family_conflict/output.txt index 8190a2eb970..2b1a78e82b7 100644 --- a/acceptance/bundle/resources/cluster_policies/definition_and_family_conflict/output.txt +++ b/acceptance/bundle/resources/cluster_policies/definition_and_family_conflict/output.txt @@ -9,3 +9,4 @@ API error_code: INVALID_PARAMETER_VALUE API message: policy_family_id and definition cannot be used together Files: 2 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed diff --git a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.deploy.txt b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.deploy.txt index 5ed7521eb52..7d629219d65 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.deploy.txt +++ b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.deploy.txt @@ -7,5 +7,6 @@ API error_code: RESOURCE_DOES_NOT_EXIST API message: Warehouse doesnotexist does not exist Files: 5 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed Exit code: 1 diff --git a/acceptance/bundle/resources/dashboards/publish-failure-stale-content/output.txt b/acceptance/bundle/resources/dashboards/publish-failure-stale-content/output.txt index f57df03fe5e..fbeffef5d8f 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-stale-content/output.txt +++ b/acceptance/bundle/resources/dashboards/publish-failure-stale-content/output.txt @@ -33,6 +33,7 @@ API error_code: INJECTED API message: Fault injected by test. Files: 1 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed Exit code: 1 diff --git a/acceptance/bundle/resources/job_runs/destroy_unfinished_run/output.txt b/acceptance/bundle/resources/job_runs/destroy_unfinished_run/output.txt index 6288dbd63c0..3b3d7bd0bd6 100644 --- a/acceptance/bundle/resources/job_runs/destroy_unfinished_run/output.txt +++ b/acceptance/bundle/resources/job_runs/destroy_unfinished_run/output.txt @@ -11,6 +11,7 @@ API error_code: INJECTED API message: Fault injected by test. Files: 1 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged, 1 failed Exit code: 1 diff --git a/acceptance/bundle/resources/job_runs/failed_run/output.txt b/acceptance/bundle/resources/job_runs/failed_run/output.txt index 44d3b17412a..dce2ec7dfac 100644 --- a/acceptance/bundle/resources/job_runs/failed_run/output.txt +++ b/acceptance/bundle/resources/job_runs/failed_run/output.txt @@ -11,6 +11,7 @@ run page: [RUN_URL] Error: cannot create resources.jobs.downstream_job: dependency failed: resources.job_runs.my_run Files: 3 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged, 2 failed === the failed run is recorded, and not having succeeded is drift >>> read_id.py my_run @@ -65,6 +66,7 @@ run page: [RUN_URL] Error: cannot create resources.jobs.downstream_job: dependency failed: resources.job_runs.my_run Files: 0 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 1 unchanged, 2 failed === run-now was issued once per deploy, and the recreate left the failed run alone >>> print_requests.py --keep //jobs/run-now diff --git a/acceptance/bundle/resources/job_runs/interrupted_run/output.txt b/acceptance/bundle/resources/job_runs/interrupted_run/output.txt index 7b911a49c38..82e74e0acd2 100644 --- a/acceptance/bundle/resources/job_runs/interrupted_run/output.txt +++ b/acceptance/bundle/resources/job_runs/interrupted_run/output.txt @@ -11,6 +11,7 @@ API error_code: INJECTED API message: Fault injected by test. Files: 1 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged, 1 failed Exit code: 1 diff --git a/acceptance/bundle/resources/jobs/cluster_policy/config_conflicts_policy/output.txt b/acceptance/bundle/resources/jobs/cluster_policy/config_conflicts_policy/output.txt index b28170e7362..4d1364dc794 100644 --- a/acceptance/bundle/resources/jobs/cluster_policy/config_conflicts_policy/output.txt +++ b/acceptance/bundle/resources/jobs/cluster_policy/config_conflicts_policy/output.txt @@ -12,6 +12,7 @@ API error_code: INVALID_PARAMETER_VALUE API message: Cluster validation error: Validation failed for custom_tags, CostCenter must be from-policy (is "not-what-the-policy-says") Files: 3 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged, 1 failed >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: diff --git a/acceptance/bundle/resources/jobs/create-error/output.txt b/acceptance/bundle/resources/jobs/create-error/output.txt index 1b4964953a5..6f5219d5a96 100644 --- a/acceptance/bundle/resources/jobs/create-error/output.txt +++ b/acceptance/bundle/resources/jobs/create-error/output.txt @@ -9,6 +9,7 @@ API error_code: INVALID_PARAMETER_VALUE API message: Shared job cluster feature is only supported in multi-task jobs. Files: 1 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed >>> musterr [CLI] bundle deploy --force-lock -q Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files... @@ -20,6 +21,7 @@ API error_code: INVALID_PARAMETER_VALUE API message: Shared job cluster feature is only supported in multi-task jobs. Files: 0 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed >>> musterr [CLI] bundle deploy --force-lock -qq Error: cannot create resources.jobs.foo: Shared job cluster feature is only supported in multi-task jobs. (400 INVALID_PARAMETER_VALUE) diff --git a/acceptance/bundle/resources/jobs/no-git-provider/out.deploy.txt b/acceptance/bundle/resources/jobs/no-git-provider/out.deploy.txt index 19ff909c96e..422c47581f4 100644 --- a/acceptance/bundle/resources/jobs/no-git-provider/out.deploy.txt +++ b/acceptance/bundle/resources/jobs/no-git-provider/out.deploy.txt @@ -13,5 +13,6 @@ API error_code: INVALID_PARAMETER_VALUE API message: git_source.git_provider must be one of: github,gitlab,bitbucketcloud,gitlabenterpriseedition,bitbucketserver,azuredevopsservices,githubenterprise,awscodecommit Files: 3 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed Exit code: 1 diff --git a/acceptance/bundle/resources/models/empty-name/out.deploy.txt b/acceptance/bundle/resources/models/empty-name/out.deploy.txt index c0048635a5c..8f0fc04c5eb 100644 --- a/acceptance/bundle/resources/models/empty-name/out.deploy.txt +++ b/acceptance/bundle/resources/models/empty-name/out.deploy.txt @@ -9,3 +9,4 @@ API error_code: INVALID_PARAMETER_VALUE API message: Got an invalid name ''. Registered Model names cannot be empty strings. Files: 3 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed diff --git a/acceptance/bundle/resources/postgres_branches/without_branch_id/out.deploy.txt b/acceptance/bundle/resources/postgres_branches/without_branch_id/out.deploy.txt index 3118fd0daea..80ba8fe8cdb 100644 --- a/acceptance/bundle/resources/postgres_branches/without_branch_id/out.deploy.txt +++ b/acceptance/bundle/resources/postgres_branches/without_branch_id/out.deploy.txt @@ -12,3 +12,4 @@ API error_code: INVALID_PARAMETER_VALUE API message: Field 'branch_id' is required, expected non-default value (not "")! Files: 0 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged, 1 failed diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/output.txt b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/output.txt index 7034fd62db6..fc481eaa442 100644 --- a/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/output.txt +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_database_id/output.txt @@ -10,6 +10,7 @@ API error_code: INVALID_PARAMETER_VALUE API message: Field database_id must match pattern ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$, got 'Invalid_DB_ID'. Files: 0 uploaded, 0 deleted +Resources: 3 created, 0 changed, 0 deleted, 0 unchanged, 1 failed >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: diff --git a/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/output.txt b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/output.txt index 207518f3b3f..7adf0260b85 100644 --- a/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/output.txt +++ b/acceptance/bundle/resources/postgres_databases/live_errors/bad_role_ref/output.txt @@ -9,6 +9,7 @@ API error_code: NOT_FOUND API message: role not found; role_id:"does-not-exist" [TraceId: [TRACE_ID]] Files: 0 uploaded, 0 deleted +Resources: 2 created, 0 changed, 0 deleted, 0 unchanged, 1 failed >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: diff --git a/acceptance/bundle/resources/postgres_endpoints/remove_suspend_timeout/out.deploy.txt b/acceptance/bundle/resources/postgres_endpoints/remove_suspend_timeout/out.deploy.txt index 062615af571..09430e8115b 100644 --- a/acceptance/bundle/resources/postgres_endpoints/remove_suspend_timeout/out.deploy.txt +++ b/acceptance/bundle/resources/postgres_endpoints/remove_suspend_timeout/out.deploy.txt @@ -7,5 +7,6 @@ API error_code: INVALID_PARAMETER_VALUE API message: Field 'spec.suspension' is in update_mask but not provided in request Files: 0 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 2 unchanged, 1 failed Exit code: 1 diff --git a/acceptance/bundle/resources/postgres_endpoints/without_endpoint_id/out.deploy.txt b/acceptance/bundle/resources/postgres_endpoints/without_endpoint_id/out.deploy.txt index 6aebf11934d..8a9865619eb 100644 --- a/acceptance/bundle/resources/postgres_endpoints/without_endpoint_id/out.deploy.txt +++ b/acceptance/bundle/resources/postgres_endpoints/without_endpoint_id/out.deploy.txt @@ -13,3 +13,4 @@ API error_code: INVALID_PARAMETER_VALUE API message: Field 'endpoint_id' is required, expected non-default value (not "")! Files: 0 uploaded, 0 deleted +Resources: 2 created, 0 changed, 0 deleted, 0 unchanged, 1 failed diff --git a/acceptance/bundle/resources/postgres_projects/add_default_endpoint_settings/out.deploy.txt b/acceptance/bundle/resources/postgres_projects/add_default_endpoint_settings/out.deploy.txt index e9fa2b4da16..cf9274827b3 100644 --- a/acceptance/bundle/resources/postgres_projects/add_default_endpoint_settings/out.deploy.txt +++ b/acceptance/bundle/resources/postgres_projects/add_default_endpoint_settings/out.deploy.txt @@ -7,5 +7,6 @@ API error_code: INVALID_PARAMETER_VALUE API message: Field 'spec.default_endpoint_settings.suspension' is in update_mask but not provided in request Files: 0 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed Exit code: 1 diff --git a/acceptance/bundle/resources/postgres_projects/remove_history_retention/out.deploy.txt b/acceptance/bundle/resources/postgres_projects/remove_history_retention/out.deploy.txt index 43f53f98801..5c835857fbb 100644 --- a/acceptance/bundle/resources/postgres_projects/remove_history_retention/out.deploy.txt +++ b/acceptance/bundle/resources/postgres_projects/remove_history_retention/out.deploy.txt @@ -7,5 +7,6 @@ API error_code: INVALID_PARAMETER_VALUE API message: Field 'spec.history_retention_duration' is in update_mask but not provided in request Files: 0 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed Exit code: 1 diff --git a/acceptance/bundle/resources/postgres_projects/without_project_id/out.deploy.txt b/acceptance/bundle/resources/postgres_projects/without_project_id/out.deploy.txt index 53dd9bc68f4..54a93b70591 100644 --- a/acceptance/bundle/resources/postgres_projects/without_project_id/out.deploy.txt +++ b/acceptance/bundle/resources/postgres_projects/without_project_id/out.deploy.txt @@ -11,3 +11,4 @@ API error_code: INVALID_PARAMETER_VALUE API message: Field 'project_id' is required, expected non-default value (not "")! Files: 0 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 0 unchanged, 1 failed diff --git a/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/output.txt b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/output.txt index f6f94cb232d..37655fb46e8 100644 --- a/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/output.txt +++ b/acceptance/bundle/resources/postgres_roles/inherited-role-conflict/output.txt @@ -28,6 +28,7 @@ API error_code: BAD_REQUEST API message: role with that name already exists Files: 0 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 2 unchanged, 1 failed >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: diff --git a/acceptance/bundle/resources/postgres_synced_tables/recreate_capped/output.txt b/acceptance/bundle/resources/postgres_synced_tables/recreate_capped/output.txt index 0ba65f672e9..145a2991334 100644 --- a/acceptance/bundle/resources/postgres_synced_tables/recreate_capped/output.txt +++ b/acceptance/bundle/resources/postgres_synced_tables/recreate_capped/output.txt @@ -14,6 +14,7 @@ Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/recreate-capped-po Error: cannot recreate resources.postgres_synced_tables.my_table: waiting after deleting id=synced_tables/lakebase_test_[UNIQUE_NAME].public.trips_synced: timed out: synced table still exists, waiting for deletion to complete Files: 0 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 3 unchanged, 1 failed Exit code: 1 diff --git a/acceptance/bundle/resources/vector_search_endpoints/recreate/create-fails/output.txt b/acceptance/bundle/resources/vector_search_endpoints/recreate/create-fails/output.txt index c2ae0b7a92a..930c245e736 100644 --- a/acceptance/bundle/resources/vector_search_endpoints/recreate/create-fails/output.txt +++ b/acceptance/bundle/resources/vector_search_endpoints/recreate/create-fails/output.txt @@ -23,6 +23,7 @@ API error_code: RESOURCE_ALREADY_EXISTS API message: Vector search endpoint with name vs-endpoint-b-[UNIQUE_NAME] already exists Files: 0 uploaded, 0 deleted +Resources: 0 created, 0 changed, 0 deleted, 1 unchanged, 1 failed Exit code: 1 diff --git a/acceptance/bundle/resources/volumes/set-storage-location/out.deploy.txt b/acceptance/bundle/resources/volumes/set-storage-location/out.deploy.txt index d92adb87392..70140187f19 100644 --- a/acceptance/bundle/resources/volumes/set-storage-location/out.deploy.txt +++ b/acceptance/bundle/resources/volumes/set-storage-location/out.deploy.txt @@ -10,3 +10,4 @@ API error_code: INVALID_PARAMETER_VALUE API message: CreateVolume storage_location can not be provided. Files: 3 uploaded, 0 deleted +Resources: 1 created, 0 changed, 0 deleted, 0 unchanged, 1 failed diff --git a/bundle/deployplan/action.go b/bundle/deployplan/action.go index e53ef0d84bd..82d6bfaf2a0 100644 --- a/bundle/deployplan/action.go +++ b/bundle/deployplan/action.go @@ -15,6 +15,10 @@ type Action struct { // StateOnly mirrors PlanEntry.StateOnly: the delete is a state-only cleanup // because the resource implements no DoDelete (deleting it has no backend effect). StateOnly bool + // Attempted and Applied mirror the same PlanEntry fields: Attempted means Apply + // reached this resource, Applied means its backend operation then succeeded. + Attempted bool + Applied bool } func (a Action) String() string { diff --git a/bundle/deployplan/plan.go b/bundle/deployplan/plan.go index 874479db43d..1f859a1d7ad 100644 --- a/bundle/deployplan/plan.go +++ b/bundle/deployplan/plan.go @@ -76,6 +76,60 @@ func (p *Plan) CountActions() ActionCounts { return c } +// AppliedCounts is CountActions restricted to what actually happened during Apply, +// plus the number of operations that were planned but did not succeed. +type AppliedCounts struct { + ActionCounts + Failed int +} + +// CountApplied tallies the plan by outcome rather than by plan: a create/change/delete +// is counted in its category only if its entry was applied successfully, an eligible +// operation that was attempted but not applied (it errored, or a failed dependency +// skipped it) is counted as Failed, and one Apply never reached is counted as neither. +// Skips are unchanged regardless, since they run no backend operation; state-only deletes +// are excluded entirely, matching CountActions. +func (p *Plan) CountApplied() AppliedCounts { + var c AppliedCounts + for _, entry := range p.Plan { + switch entry.Action { + case Create: + if entry.Applied { + c.Create++ + } else if entry.Attempted { + c.Failed++ + } + case Update, UpdateWithID, Resize: + if entry.Applied { + c.Change++ + } else if entry.Attempted { + c.Failed++ + } + case Delete: + if entry.IsStateOnlyDelete() { + continue + } + if entry.Applied { + c.Delete++ + } else if entry.Attempted { + c.Failed++ + } + case Recreate: + // A recreate is one unit: on success it counts as both a delete and a + // create, on failure as a single failed operation. + if entry.Applied { + c.Delete++ + c.Create++ + } else if entry.Attempted { + c.Failed++ + } + case Skip, Undefined: + c.Unchanged++ + } + } + return c +} + // NewPlanDirect creates a new Plan for direct engine with plan_version set. func NewPlanDirect() *Plan { return &Plan{ @@ -131,6 +185,15 @@ type PlanEntry struct { NewState *structvar.StructVarJSON `json:"new_state,omitempty"` RemoteState any `json:"remote_state,omitempty"` Changes Changes `json:"changes,omitempty"` + + // Attempted is set when Apply starts processing this entry (the apply graph ran and + // reached this node); Applied is set when its backend operation then succeeded. Both + // are runtime-only (never serialized). Together they let the deploy/destroy summaries + // report what actually happened - applied, or attempted-but-failed - rather than the + // planned count. An entry Apply never reached (e.g. a config error bailed out before + // the graph ran) has neither set and is reported as neither applied nor failed. + Attempted bool `json:"-"` + Applied bool `json:"-"` } // IsStateOnlyDelete reports whether applying this delete only drops the state entry @@ -234,6 +297,8 @@ func (p *Plan) GetActions() []Action { ActionType: entry.Action, Gone: entry.Gone, StateOnly: entry.StateOnly, + Attempted: entry.Attempted, + Applied: entry.Applied, }) } diff --git a/bundle/deployplan/plan_test.go b/bundle/deployplan/plan_test.go index cee00f42c44..c04d6cf66c6 100644 --- a/bundle/deployplan/plan_test.go +++ b/bundle/deployplan/plan_test.go @@ -65,3 +65,68 @@ func TestHasChange(t *testing.T) { }) } } + +func TestCountApplied(t *testing.T) { + tests := []struct { + name string + entry deployplan.PlanEntry + want deployplan.AppliedCounts + }{ + { + name: "create applied", + entry: deployplan.PlanEntry{Action: deployplan.Create, Attempted: true, Applied: true}, + want: deployplan.AppliedCounts{ActionCounts: deployplan.ActionCounts{Create: 1}}, + }, + { + name: "create attempted but failed", + entry: deployplan.PlanEntry{Action: deployplan.Create, Attempted: true}, + want: deployplan.AppliedCounts{Failed: 1}, + }, + { + name: "create never reached is neither applied nor failed", + entry: deployplan.PlanEntry{Action: deployplan.Create}, + want: deployplan.AppliedCounts{}, + }, + { + name: "update applied counts as changed", + entry: deployplan.PlanEntry{Action: deployplan.Update, Attempted: true, Applied: true}, + want: deployplan.AppliedCounts{ActionCounts: deployplan.ActionCounts{Change: 1}}, + }, + { + name: "delete applied", + entry: deployplan.PlanEntry{Action: deployplan.Delete, Attempted: true, Applied: true}, + want: deployplan.AppliedCounts{ActionCounts: deployplan.ActionCounts{Delete: 1}}, + }, + { + name: "delete attempted but failed", + entry: deployplan.PlanEntry{Action: deployplan.Delete, Attempted: true}, + want: deployplan.AppliedCounts{Failed: 1}, + }, + { + name: "state-only delete is excluded", + entry: deployplan.PlanEntry{Action: deployplan.Delete, StateOnly: true, Attempted: true}, + want: deployplan.AppliedCounts{}, + }, + { + name: "recreate applied counts as both create and delete", + entry: deployplan.PlanEntry{Action: deployplan.Recreate, Attempted: true, Applied: true}, + want: deployplan.AppliedCounts{ActionCounts: deployplan.ActionCounts{Create: 1, Delete: 1}}, + }, + { + name: "recreate failed counts as a single failure", + entry: deployplan.PlanEntry{Action: deployplan.Recreate, Attempted: true}, + want: deployplan.AppliedCounts{Failed: 1}, + }, + { + name: "skip is unchanged regardless of attempt", + entry: deployplan.PlanEntry{Action: deployplan.Skip}, + want: deployplan.AppliedCounts{ActionCounts: deployplan.ActionCounts{Unchanged: 1}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &deployplan.Plan{Plan: map[string]*deployplan.PlanEntry{"resources.jobs.a": &tt.entry}} + assert.Equal(t, tt.want, p.CountApplied()) + }) + } +} diff --git a/bundle/direct/bundle_apply.go b/bundle/direct/bundle_apply.go index a673a3239c8..15709ecd773 100644 --- a/bundle/direct/bundle_apply.go +++ b/bundle/direct/bundle_apply.go @@ -65,6 +65,11 @@ func (b *DeploymentBundle) Apply(ctx context.Context, client *databricks.Workspa defer plan.WriteUnlockEntry(resourceKey) + // Mark the entry attempted: the graph ran and reached this node. Together with + // Applied (set on success below) this lets the summary distinguish a resource that + // was attempted and failed from one Apply never reached. + entry.Attempted = true + action := entry.Action errorPrefix := fmt.Sprintf("cannot %s %s", action, resourceKey) @@ -132,6 +137,7 @@ func (b *DeploymentBundle) Apply(ctx context.Context, client *databricks.Workspa if reportApplied && !entry.IsStateOnlyDelete() { cmdio.LogString(ctx, deployplan.AppliedLine(resourceKey, action)) } + entry.Applied = true return true } @@ -168,8 +174,10 @@ func (b *DeploymentBundle) Apply(ctx context.Context, client *databricks.Workspa return false } - // Reported before the remote-state refresh below: the resource is already - // deployed at this point, so the line is accurate even if the refresh fails. + // Marked (and reported) before the remote-state refresh below: the resource is + // already deployed at this point, so the summary count and the line are accurate + // even if the refresh fails. + entry.Applied = true if reportApplied { cmdio.LogString(ctx, deployplan.AppliedLine(resourceKey, action)) } diff --git a/bundle/phases/deploy.go b/bundle/phases/deploy.go index 13c5121f569..af2808ca9eb 100644 --- a/bundle/phases/deploy.go +++ b/bundle/phases/deploy.go @@ -120,8 +120,9 @@ func logFileSummary(ctx context.Context, b *bundle.Bundle) { cmdio.LogString(ctx, fmt.Sprintf("Files: %d uploaded, %d deleted", b.FileCounts.Uploaded, b.FileCounts.Deleted)) } -// logDeploySummary prints the resource summary line. -qq drops it too. The direct engine -// prints each per-resource applied line itself as it goes, so there are none to report here. +// logDeploySummary prints the resource summary line. -qq drops it too. The counts reflect +// what Apply actually did (CountApplied), so the line is accurate on a partial failure too; +// per-resource applied lines are printed by the engine as it goes, not here. func logDeploySummary(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan) { if b.Quiet >= bundle.QuietAll { return @@ -129,8 +130,11 @@ func logDeploySummary(ctx context.Context, b *bundle.Bundle, plan *deployplan.Pl logFileSummary(ctx, b) - counts := plan.CountActions() + counts := plan.CountApplied() summary := fmt.Sprintf("Resources: %d created, %d changed, %d deleted, %d unchanged", counts.Create, counts.Change, counts.Delete, counts.Unchanged) + if counts.Failed > 0 { + summary += fmt.Sprintf(", %d failed", counts.Failed) + } // Gate on the plan's own NotSelected (not b.Select) so the suffix survives a // deploy from a --plan file, where --select was applied at plan time and // b.Select is empty here. NotSelected is only ever set by FilterToSelected. @@ -351,20 +355,21 @@ func Deploy(ctx context.Context, b *bundle.Bundle, outputHandler sync.OutputHand deployCore(ctx, b, plan) + // Report what was deployed, mirroring "bundle plan". Printed before the postdeploy + // script so the deploy's own report is not interleaved with post-deploy output. The + // counts come from CountApplied, so they describe what Apply actually did: on a + // partial resource failure this still reports what succeeded and what failed. A + // failure with no failed resources (e.g. a later state-push error) reports files + // only, as before - see acceptance bundle/deploy/partial-summary-on-push-fail. + if !logdiag.HasError(ctx) || plan.CountApplied().Failed > 0 { + filesReported = true + logDeploySummary(ctx, b, plan) + } + if logdiag.HasError(ctx) { return } - // Report what was deployed, mirroring "bundle plan". Printed before the - // postdeploy script so the deploy's own report is not interleaved with - // post-deploy output: the script's lines and the migration's below both follow - // it, and neither reads as belonging to the deploy. The resources were applied - // above, so the counts are accurate however the script turns out, and its error - // still propagates. Earlier failures report the files only, since the plan - // counts would then describe what was intended rather than what was applied. - filesReported = true - logDeploySummary(ctx, b, plan) - bundle.ApplyContext(ctx, b, scripts.Execute(config.ScriptPostDeploy)) } diff --git a/bundle/phases/destroy.go b/bundle/phases/destroy.go index f8445476b7a..20fc3b0e434 100644 --- a/bundle/phases/destroy.go +++ b/bundle/phases/destroy.go @@ -138,21 +138,32 @@ func approvalForDestroy(ctx context.Context, b *bundle.Bundle, plan *deployplan. return cmdio.AskYesOrNo(ctx, "Would you like to proceed?") } -// logDestroySummary prints the destroy summary showing how many resources were deleted. -// This is called even when destroy errors occur, since partial deletions may have succeeded. +// logDestroySummary prints the destroy summary showing how many resources were deleted +// and, if the destroy errored partway, how many failed. Called even on error, since +// partial deletions may have succeeded. func logDestroySummary(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan) { if b.Quiet < bundle.QuietAll { // Count top-level resources only, matching the approval list above (which // skips children). Gone resources are excluded to match that list: they were // already deleted remotely, so applying their Delete only cleans up stale - // state and is not a destruction to report. - deleted := 0 + // state and is not a destruction to report. A delete that was applied counts + // as deleted; an eligible one that was not counts as failed. + deleted, failed := 0, 0 for _, a := range plan.GetActions() { - if a.ActionType == deployplan.Delete && !a.IsChildResource() && !a.IsStateOnlyDelete() { + if a.ActionType != deployplan.Delete || a.IsChildResource() || a.IsStateOnlyDelete() { + continue + } + if a.Applied { deleted++ + } else if a.Attempted { + failed++ } } - cmdio.LogString(ctx, fmt.Sprintf("Destroy: %d deleted", deleted)) + summary := fmt.Sprintf("Destroy: %d deleted", deleted) + if failed > 0 { + summary += fmt.Sprintf(", %d failed", failed) + } + cmdio.LogString(ctx, summary) } }