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/.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/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/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..d8afafbdf91 --- /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: 2 deleted, 1 failed 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..bcb92b24917 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: 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 8b5497d626a..20fc3b0e434 100644 --- a/bundle/phases/destroy.go +++ b/bundle/phases/destroy.go @@ -138,7 +138,39 @@ 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 +// 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. 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() { + continue + } + if a.Applied { + deleted++ + } else if a.Attempted { + failed++ + } + } + summary := fmt.Sprintf("Destroy: %d deleted", deleted) + if failed > 0 { + summary += fmt.Sprintf(", %d failed", failed) + } + cmdio.LogString(ctx, summary) + } +} + 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 +209,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 }