diff --git a/acceptance/bundle/deployment/bind/test.toml b/acceptance/bundle/deployment/bind/test.toml index b693e4ded26..fd714a39b3e 100644 --- a/acceptance/bundle/deployment/bind/test.toml +++ b/acceptance/bundle/deployment/bind/test.toml @@ -1,2 +1,3 @@ -# Bind operations are not yet supported by the Deployment Metadata Service (DMS) +# These tests check the local state file after bind; with the Deployment Metadata Service (DMS) the +# bind is recorded with the service instead, which bundle/dms/bind covers. EnvMatrix.DMS = [""] diff --git a/acceptance/bundle/dms/bind-unbind-unsupported/script b/acceptance/bundle/dms/bind-unbind-unsupported/script deleted file mode 100644 index 03d9cf34a31..00000000000 --- a/acceptance/bundle/dms/bind-unbind-unsupported/script +++ /dev/null @@ -1,14 +0,0 @@ -envsubst < databricks.yml.tmpl > databricks.yml - -# The gate that refuses bind while recording deployment history is on the config, so it fires -# before any deploy. Create a real workspace job so bind gets past fetching the resource and -# reaches the gate, instead of failing because the resource does not exist. -title "bind is refused while recording deployment history" -job_id=$($CLI jobs create --json '{"name": "bind-target-'$UNIQUE_NAME'"}' | jq -r '.job_id') -add_repl "$job_id" JOB_ID -trace musterr $CLI bundle deployment bind foo "$job_id" - -# unbind refuses when it opens an existing recording state, so it needs a deployment first. -title "Deploy so the bundle records state, then unbind is refused too" -trace $CLI bundle deploy -trace musterr $CLI bundle deployment unbind foo diff --git a/acceptance/bundle/dms/bind/databricks.yml.tmpl b/acceptance/bundle/dms/bind/databricks.yml.tmpl new file mode 100644 index 00000000000..e5b40a1ed15 --- /dev/null +++ b/acceptance/bundle/dms/bind/databricks.yml.tmpl @@ -0,0 +1,18 @@ +bundle: + name: dms-bind-$UNIQUE_NAME +experimental: + deployment_history: true + +resources: + schemas: + sc: + name: dms_bind_$UNIQUE_NAME + catalog_name: main + jobs: + foo: + name: foo + permissions: + - group_name: users + level: CAN_MANAGE + bar: + name: bar diff --git a/acceptance/bundle/dms/bind/out.test.toml b/acceptance/bundle/dms/bind/out.test.toml new file mode 100644 index 00000000000..84b22ba6735 --- /dev/null +++ b/acceptance/bundle/dms/bind/out.test.toml @@ -0,0 +1,2 @@ +Cloud = true +EnvMatrix.DMS = ["true"] diff --git a/acceptance/bundle/dms/bind/output.txt b/acceptance/bundle/dms/bind/output.txt new file mode 100644 index 00000000000..daebdf3c3a6 --- /dev/null +++ b/acceptance/bundle/dms/bind/output.txt @@ -0,0 +1,121 @@ + +>>> [CLI] schemas create dms_bind_[UNIQUE_NAME] main +{ + "full_name": "main.dms_bind_[UNIQUE_NAME]" +} + +=== A missing resource cannot be bound +>>> musterr [CLI] bundle deployment bind sc main.missing_[UNIQUE_NAME] --auto-approve +Error: schema with an id 'main.missing_[UNIQUE_NAME]' is not found + +=== Bind sc, the first bind: it creates the deployment and records the schema as is +>>> [CLI] bundle deployment bind sc main.dms_bind_[UNIQUE_NAME] +Successfully bound schema with an id 'main.dms_bind_[UNIQUE_NAME]' +Run 'bundle deploy' to deploy changes to your workspace + +=== Version 1 records only sc, with the bind action type +>>> print_requests.py --dms --method POST //versions --nostamp +{ + "method": "POST", + "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", + "q": { + "version_id": "1" + }, + "body": { + "cli_version": "[CLI_VERSION]", + "operations": [ + { + "action_type": "OPERATION_ACTION_TYPE_BIND", + "resource_key": "schemas.sc" + } + ], + "version_type": "VERSION_TYPE_DEPLOY" + } +} +{ + "method": "POST", + "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/1/complete", + "body": { + "completion_reason": "VERSION_COMPLETE_SUCCESS" + } +} + +=== foo's config differs from the existing job, so binding it asks for approval +=== Bind foo: version 2 records only foo, and the job is left unchanged +>>> [CLI] bundle deployment bind foo [FOO_ID] --auto-approve +Successfully bound job with an id '[FOO_ID]' +Run 'bundle deploy' to deploy changes to your workspace + +>>> print_requests.py --dms --method POST //versions --nostamp +{ + "method": "POST", + "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", + "q": { + "version_id": "2" + }, + "body": { + "cli_version": "[CLI_VERSION]", + "operations": [ + { + "action_type": "OPERATION_ACTION_TYPE_BIND", + "resource_key": "jobs.foo" + } + ], + "previous_version_id": "1", + "version_type": "VERSION_TYPE_DEPLOY" + } +} +{ + "method": "POST", + "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/2/complete", + "body": { + "completion_reason": "VERSION_COMPLETE_SUCCESS" + } +} + +>>> [CLI] jobs get [FOO_ID] +{ + "name": "bind-target-[UNIQUE_NAME]" +} + +=== An already-bound resource cannot be rebound +>>> musterr [CLI] bundle deployment bind foo [FOO_ID] --auto-approve +Error: Resource already managed + +The bundle is already managing resource for resources.jobs.foo and it is bound to the requested ID [FOO_ID]. + + +=== The next deploy applies foo's config and permissions, and creates bar +>>> [CLI] bundle plan +create jobs.bar +update jobs.foo +create jobs.foo.permissions + +Plan: 2 to add, 1 to change, 0 to delete, 1 unchanged + +>>> [CLI] bundle deploy --auto-approve +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/dms-bind-[UNIQUE_NAME]/default/files... +Created jobs.bar +Created jobs.foo.permissions +Updated jobs.foo +Files: 4 uploaded, 0 deleted +Resources: 2 created, 1 changed, 0 deleted, 1 unchanged + +>>> [CLI] jobs get [FOO_ID] +{ + "name": "foo" +} + +=== Test cleanup: the bound resources are now bundle-managed, so destroy removes them +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.jobs.bar + delete resources.jobs.foo + delete resources.schemas.sc + +This action will result in the deletion of the following UC schemas. Any underlying data may be lost: + delete resources.schemas.sc + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/dms-bind-[UNIQUE_NAME]/default + +Destroy: 3 deleted diff --git a/acceptance/bundle/dms/bind/script b/acceptance/bundle/dms/bind/script new file mode 100644 index 00000000000..a02b2efda8c --- /dev/null +++ b/acceptance/bundle/dms/bind/script @@ -0,0 +1,39 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +# sc's config matches this existing schema exactly. +trace $CLI schemas create "dms_bind_$UNIQUE_NAME" main | jq '{full_name}' +# foo's config ("foo") differs from this existing job. +foo_id=$($CLI jobs create --json '{"name": "bind-target-'$UNIQUE_NAME'"}' | jq -r '.job_id') +add_repl "$foo_id" FOO_ID + +cleanup() { + title "Test cleanup: the bound resources are now bundle-managed, so destroy removes them" + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +title "A missing resource cannot be bound" +trace musterr $CLI bundle deployment bind sc "main.missing_$UNIQUE_NAME" --auto-approve + +title "Bind sc, the first bind: it creates the deployment and records the schema as is" +trace $CLI bundle deployment bind sc "main.dms_bind_$UNIQUE_NAME" + +title "Version 1 records only sc, with the bind action type" +trace print_requests.py --dms --method POST //versions --nostamp + +title "foo's config differs from the existing job, so binding it asks for approval" +trace musterr $CLI bundle deployment bind foo "$foo_id" &> LOG.confirm +contains.py 'requires user confirmation' 'Plan: update resources.jobs.foo' < LOG.confirm > LOG.confirm.checked + +title "Bind foo: version 2 records only foo, and the job is left unchanged" +trace $CLI bundle deployment bind foo "$foo_id" --auto-approve +trace print_requests.py --dms --method POST //versions --nostamp +trace $CLI jobs get "$foo_id" | jq '{name: .settings.name}' + +title "An already-bound resource cannot be rebound" +trace musterr $CLI bundle deployment bind foo "$foo_id" --auto-approve + +title "The next deploy applies foo's config and permissions, and creates bar" +trace $CLI bundle plan +trace $CLI bundle deploy --auto-approve +trace $CLI jobs get "$foo_id" | jq '{name: .settings.name}' diff --git a/acceptance/bundle/dms/record-op-fault/databricks.yml.tmpl b/acceptance/bundle/dms/record-op-fault/databricks.yml.tmpl new file mode 100644 index 00000000000..235760efa24 --- /dev/null +++ b/acceptance/bundle/dms/record-op-fault/databricks.yml.tmpl @@ -0,0 +1,9 @@ +bundle: + name: dms-record-op-fault-$UNIQUE_NAME +experimental: + deployment_history: true + +resources: + jobs: + foo: + name: foo diff --git a/acceptance/bundle/dms/bind-unbind-unsupported/out.test.toml b/acceptance/bundle/dms/record-op-fault/out.test.toml similarity index 100% rename from acceptance/bundle/dms/bind-unbind-unsupported/out.test.toml rename to acceptance/bundle/dms/record-op-fault/out.test.toml diff --git a/acceptance/bundle/dms/record-op-fault/output.txt b/acceptance/bundle/dms/record-op-fault/output.txt new file mode 100644 index 00000000000..4f22dbfa54b --- /dev/null +++ b/acceptance/bundle/dms/record-op-fault/output.txt @@ -0,0 +1,71 @@ + +=== The service rejects the operation upload, so recording the bind fails +>>> fault.py PATCH /api/2.0/bundle/* 500 0 1 + +>>> musterr [CLI] bundle deployment bind foo [JOB_ID] --auto-approve +Error: recording operation for resources.jobs.foo: Fault injected by test. (500 INJECTED) + +Endpoint: PATCH [DATABRICKS_URL]/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/1/operations/jobs.foo?update_mask=state%2Cerror_message%2Cresource_id%2Cstatus +HTTP Status: 500 Internal Server Error +API error_code: INJECTED +API message: Fault injected by test. + + +=== The version is still completed - with failure - rather than left holding its lease +>>> print_requests.py --dms --method POST //versions +{ + "method": "POST", + "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", + "q": { + "version_id": "1" + }, + "body": { + "cli_version": "[CLI_VERSION]", + "operations": [ + { + "action_type": "OPERATION_ACTION_TYPE_BIND", + "resource_key": "jobs.foo" + } + ], + "version_type": "VERSION_TYPE_DEPLOY" + } +} +{ + "method": "POST", + "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/1/complete", + "body": { + "completion_reason": "VERSION_COMPLETE_FAILURE" + } +} + +=== Retrying the bind succeeds +>>> [CLI] bundle deployment bind foo [JOB_ID] --auto-approve +Successfully bound job with an id '[JOB_ID]' +Run 'bundle deploy' to deploy changes to your workspace + +>>> print_requests.py --dms --method POST //versions +{ + "method": "POST", + "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions", + "q": { + "version_id": "2" + }, + "body": { + "cli_version": "[CLI_VERSION]", + "operations": [ + { + "action_type": "OPERATION_ACTION_TYPE_BIND", + "resource_key": "jobs.foo" + } + ], + "previous_version_id": "1", + "version_type": "VERSION_TYPE_DEPLOY" + } +} +{ + "method": "POST", + "path": "/api/2.0/bundle/deployments/[DEPLOYMENT_ID]/versions/2/complete", + "body": { + "completion_reason": "VERSION_COMPLETE_SUCCESS" + } +} diff --git a/acceptance/bundle/dms/record-op-fault/script b/acceptance/bundle/dms/record-op-fault/script new file mode 100644 index 00000000000..d03f61a923b --- /dev/null +++ b/acceptance/bundle/dms/record-op-fault/script @@ -0,0 +1,15 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +job_id=$($CLI jobs create --json '{"name": "target-'$UNIQUE_NAME'"}' | jq -r '.job_id') +add_repl "$job_id" JOB_ID + +title "The service rejects the operation upload, so recording the bind fails" +trace fault.py "PATCH /api/2.0/bundle/*" 500 0 1 +trace musterr $CLI bundle deployment bind foo "$job_id" --auto-approve + +title "The version is still completed - with failure - rather than left holding its lease" +trace print_requests.py --dms --method POST //versions + +title "Retrying the bind succeeds" +trace $CLI bundle deployment bind foo "$job_id" --auto-approve +trace print_requests.py --dms --method POST //versions diff --git a/acceptance/bundle/dms/record-op-fault/test.toml b/acceptance/bundle/dms/record-op-fault/test.toml new file mode 100644 index 00000000000..24f1970a8f2 --- /dev/null +++ b/acceptance/bundle/dms/record-op-fault/test.toml @@ -0,0 +1,3 @@ +# Local only: the case needs an injected failure (DMS must reject the operation upload once), and +# fault.py registers its rule on the fake, which a cloud run does not have. +Cloud = false diff --git a/acceptance/bundle/dms/bind-unbind-unsupported/databricks.yml.tmpl b/acceptance/bundle/dms/unbind-unsupported/databricks.yml.tmpl similarity index 71% rename from acceptance/bundle/dms/bind-unbind-unsupported/databricks.yml.tmpl rename to acceptance/bundle/dms/unbind-unsupported/databricks.yml.tmpl index e60f4605610..6ba28905b76 100644 --- a/acceptance/bundle/dms/bind-unbind-unsupported/databricks.yml.tmpl +++ b/acceptance/bundle/dms/unbind-unsupported/databricks.yml.tmpl @@ -1,5 +1,5 @@ bundle: - name: dms-bind-unbind-$UNIQUE_NAME + name: dms-unbind-$UNIQUE_NAME experimental: deployment_history: true diff --git a/acceptance/bundle/dms/unbind-unsupported/out.test.toml b/acceptance/bundle/dms/unbind-unsupported/out.test.toml new file mode 100644 index 00000000000..88e6fbd847c --- /dev/null +++ b/acceptance/bundle/dms/unbind-unsupported/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DMS = ["true"] diff --git a/acceptance/bundle/dms/bind-unbind-unsupported/output.txt b/acceptance/bundle/dms/unbind-unsupported/output.txt similarity index 51% rename from acceptance/bundle/dms/bind-unbind-unsupported/output.txt rename to acceptance/bundle/dms/unbind-unsupported/output.txt index d2d0a366778..a1391c4aa08 100644 --- a/acceptance/bundle/dms/bind-unbind-unsupported/output.txt +++ b/acceptance/bundle/dms/unbind-unsupported/output.txt @@ -1,12 +1,7 @@ -=== bind is refused while recording deployment history ->>> musterr [CLI] bundle deployment bind foo [JOB_ID] -Error: bind is not supported for a bundle target that records deployment history - - -=== Deploy so the bundle records state, then unbind is refused too +=== Deploy so the bundle records state, then unbind is refused >>> [CLI] bundle deploy -Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/dms-bind-unbind-[UNIQUE_NAME]/default/files... +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/dms-unbind-[UNIQUE_NAME]/default/files... Created jobs.foo Files: 2 uploaded, 0 deleted Resources: 1 created, 0 changed, 0 deleted, 0 unchanged diff --git a/acceptance/bundle/dms/unbind-unsupported/script b/acceptance/bundle/dms/unbind-unsupported/script new file mode 100644 index 00000000000..d72aafe94b5 --- /dev/null +++ b/acceptance/bundle/dms/unbind-unsupported/script @@ -0,0 +1,6 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +# unbind refuses when it opens an existing recording state, so it needs a deployment first. +title "Deploy so the bundle records state, then unbind is refused" +trace $CLI bundle deploy +trace musterr $CLI bundle deployment unbind foo diff --git a/acceptance/bundle/dms/bind-unbind-unsupported/test.toml b/acceptance/bundle/dms/unbind-unsupported/test.toml similarity index 58% rename from acceptance/bundle/dms/bind-unbind-unsupported/test.toml rename to acceptance/bundle/dms/unbind-unsupported/test.toml index 140ee938238..267d07f61de 100644 --- a/acceptance/bundle/dms/bind-unbind-unsupported/test.toml +++ b/acceptance/bundle/dms/unbind-unsupported/test.toml @@ -1,4 +1,4 @@ RecordRequests = false -# bind/unbind refusal is a client-side gate on the recording config; it needs no real +# The unbind refusal is a client-side gate on the recording config; it needs no real # workspace, so this stays local-only rather than inheriting the suite's cloud run. Cloud = false diff --git a/bundle/phases/bind.go b/bundle/phases/bind.go index 211987f5ec9..0efd066607e 100644 --- a/bundle/phases/bind.go +++ b/bundle/phases/bind.go @@ -12,10 +12,12 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config/engine" "github.com/databricks/cli/bundle/deploy/lock" + "github.com/databricks/cli/bundle/deploy/metadata" "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/direct" "github.com/databricks/cli/bundle/statemgmt" "github.com/databricks/cli/libs/agent" + "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/logdiag" @@ -57,17 +59,28 @@ func Bind(ctx context.Context, b *bundle.Bundle, opts *BindOptions, stateDesc *s return } - if stateDesc.IsDMS() { - logdiag.LogError(ctx, errors.New("bind is not supported for a bundle target that records deployment history")) - return - } - resourceKey := fmt.Sprintf("resources.%s.%s", opts.Group, opts.ResourceKey) if existingID := db.GetResourceID(resourceKey); existingID != "" { logdiag.LogError(ctx, direct.ErrResourceAlreadyBound{ResourceKey: resourceKey, ExistingID: existingID, NewID: opts.ResourceId}) return } + if stateDesc.IsDMS() { + // Stamp the deployment metadata deploy stamps, so the recorded state matches the config. + muts := []bundle.Mutator{metadata.AnnotateDeploymentVersion(db.VersionID + 1)} + if db.DeploymentID != "" { + muts = append(muts, metadata.AnnotateDeployment(db.DeploymentID)) + } + bundle.ApplySeqContext(ctx, b, muts...) + if logdiag.HasError(ctx) { + return + } + // The operation buffer records each state write through the workspace client on the context. + if !cmdctx.HasWorkspaceClient(ctx) { + ctx = cmdctx.SetWorkspaceClient(ctx, b.WorkspaceClient(ctx)) + } + } + // Plan the resource as a bind of the existing workspace resource, compared against the config. b.DeploymentBundle.BindKey = resourceKey b.DeploymentBundle.BindID = opts.ResourceId @@ -125,10 +138,22 @@ func Bind(ctx context.Context, b *bundle.Bundle, opts *BindOptions, stateDesc *s logdiag.LogError(ctx, err) return } + // With deployment history the bind is recorded as its own version. + if stateDesc.IsDMS() && !startBindVersion(ctx, b, plan) { + return + } b.DeploymentBundle.Apply(ctx, b.WorkspaceClient(ctx), plan, false) if _, err := db.Finalize(ctx); err != nil { logdiag.LogError(ctx, err) } + if stateDesc.IsDMS() { + // As in deploy, push the state even after a failure, then close the version out. + statemgmt.PushResourcesState(ctx, b) + if _, err := db.CompleteVersion(ctx, !logdiag.HasError(ctx)); err != nil { + logdiag.LogError(ctx, err) + } + return + } if logdiag.HasError(ctx) { return } diff --git a/bundle/phases/dms.go b/bundle/phases/dms.go index b351c04640d..ddd469b7675 100644 --- a/bundle/phases/dms.go +++ b/bundle/phases/dms.go @@ -51,6 +51,8 @@ func actionToSDK(a deployplan.ActionType) (bundledeployments.OperationActionType switch a { case deployplan.Create: return bundledeployments.OperationActionTypeOperationActionTypeCreate, nil + case deployplan.Bind: + return bundledeployments.OperationActionTypeOperationActionTypeBind, nil case deployplan.Update: return bundledeployments.OperationActionTypeOperationActionTypeUpdate, nil case deployplan.UpdateWithID: @@ -118,6 +120,34 @@ func createOrUpdateDeployment(ctx context.Context, b *bundle.Bundle, current *bu } } +// startBindVersion creates the deployment on a first bind (or refreshes its metadata) and starts the +// version that records the bind. It reports whether the version started; on failure it has logged. +func startBindVersion(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan) bool { + var current *bundledeployments.Deployment + if id := b.DeploymentBundle.StateDB.DeploymentID; id != "" { + var err error + current, err = b.WorkspaceClient(ctx).BundleDeployments.GetDeployment(ctx, bundledeployments.GetDeploymentRequest{Name: dms.DeploymentName(id)}) + if err != nil { + logdiag.LogError(ctx, err) + return false + } + } + createOrUpdateDeployment(ctx, b, current) + if logdiag.HasError(ctx) { + return false + } + staged, err := stagedOperations(plan) + if err != nil { + logdiag.LogError(ctx, err) + return false + } + if err := startVersion(ctx, b, dms.VersionTypeDeploy, staged); err != nil { + logdiag.LogError(ctx, err) + return false + } + return true +} + // startVersion claims the version the run settled on and opens the buffer that records // each state write under it. Called after approval, so a declined deploy never claims a number. // A no-op when the bundle does not record deployment history. diff --git a/bundle/phases/dms_test.go b/bundle/phases/dms_test.go index 6b045f863ae..5c2aab34923 100644 --- a/bundle/phases/dms_test.go +++ b/bundle/phases/dms_test.go @@ -17,6 +17,7 @@ func TestStagedOperationsCoversEveryTouchedResource(t *testing.T) { "resources.pipelines.bar": {Action: deployplan.Recreate}, "resources.schemas.baz": {Action: deployplan.Delete}, "resources.clusters.small": {Action: deployplan.Resize}, + "resources.jobs.bound": {Action: deployplan.Bind}, }} staged, err := stagedOperations(plan) @@ -27,6 +28,7 @@ func TestStagedOperationsCoversEveryTouchedResource(t *testing.T) { {ResourceKey: "pipelines.bar", ActionType: bundledeployments.OperationActionTypeOperationActionTypeRecreate}, {ResourceKey: "schemas.baz", ActionType: bundledeployments.OperationActionTypeOperationActionTypeDelete}, {ResourceKey: "clusters.small", ActionType: bundledeployments.OperationActionTypeOperationActionTypeResize}, + {ResourceKey: "jobs.bound", ActionType: bundledeployments.OperationActionTypeOperationActionTypeBind}, }, staged) } @@ -59,6 +61,7 @@ func TestActionToSDK(t *testing.T) { want bundledeployments.OperationActionType }{ {deployplan.Create, bundledeployments.OperationActionTypeOperationActionTypeCreate}, + {deployplan.Bind, bundledeployments.OperationActionTypeOperationActionTypeBind}, {deployplan.Update, bundledeployments.OperationActionTypeOperationActionTypeUpdate}, {deployplan.UpdateWithID, bundledeployments.OperationActionTypeOperationActionTypeUpdateWithId}, {deployplan.Recreate, bundledeployments.OperationActionTypeOperationActionTypeRecreate},