diff --git a/acceptance/bundle/dms/plan-and-summary/databricks.yml b/acceptance/bundle/dms/plan-and-summary/databricks.yml new file mode 100644 index 00000000000..9529e5b8c9b --- /dev/null +++ b/acceptance/bundle/dms/plan-and-summary/databricks.yml @@ -0,0 +1,7 @@ +bundle: + name: dms-plan-and-summary + +resources: + jobs: + test_job: + name: test-job diff --git a/acceptance/bundle/dms/plan-and-summary/out.test.toml b/acceptance/bundle/dms/plan-and-summary/out.test.toml new file mode 100644 index 00000000000..9b50a81b196 --- /dev/null +++ b/acceptance/bundle/dms/plan-and-summary/out.test.toml @@ -0,0 +1,4 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.DATABRICKS_BUNDLE_MANAGED_STATE = ["true"] diff --git a/acceptance/bundle/dms/plan-and-summary/output.txt b/acceptance/bundle/dms/plan-and-summary/output.txt new file mode 100644 index 00000000000..014eaa411ae --- /dev/null +++ b/acceptance/bundle/dms/plan-and-summary/output.txt @@ -0,0 +1,61 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/dms-plan-and-summary/default/files... +Deploying resources... +Deployment complete! + +>>> [CLI] bundle plan +create jobs.test_job + +Plan: 1 to add, 0 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle summary +Name: dms-plan-and-summary +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/dms-plan-and-summary/default +Resources: + Jobs: + test_job: + Name: test-job + URL: (not deployed) + +>>> print_requests.py --get //bundle ^//workspace-files ^//import-file +{ + "method": "POST", + "path": "/api/2.0/bundle/deployments", + "q": { + "deployment_id": "[UUID]" + }, + "body": { + "target_name": "default" + } +} +{ + "method": "POST", + "path": "/api/2.0/bundle/deployments/[UUID]/versions", + "q": { + "version_id": "1" + }, + "body": { + "cli_version": "[DEV_VERSION]", + "target_name": "default", + "version_type": "VERSION_TYPE_DEPLOY" + } +} +{ + "method": "POST", + "path": "/api/2.0/bundle/deployments/[UUID]/versions/1/complete", + "body": { + "completion_reason": "VERSION_COMPLETE_SUCCESS" + } +} +{ + "method": "GET", + "path": "/api/2.0/bundle/deployments/[UUID]/resources" +} +{ + "method": "GET", + "path": "/api/2.0/bundle/deployments/[UUID]/resources" +} diff --git a/acceptance/bundle/dms/plan-and-summary/script b/acceptance/bundle/dms/plan-and-summary/script new file mode 100644 index 00000000000..8b2d0def20c --- /dev/null +++ b/acceptance/bundle/dms/plan-and-summary/script @@ -0,0 +1,17 @@ +# Deploy first so managed_service.json exists with a deployment_id. +# Step 5 will report individual operations here; for now DMS only knows the +# deployment exists. +trace $CLI bundle deploy + +# bundle plan should hit DMS via ListResources. With no operations reported +# yet, the resource list is empty and the job is planned as a create. +trace $CLI bundle plan + +# bundle summary should also read from DMS. ListResources is empty, so the +# job renders as (not deployed). +trace $CLI bundle summary + +# Verify the metadata-service calls. Two ListResources lines confirm that +# both plan and summary went through the DMS read path. +trace print_requests.py --get //bundle ^//workspace-files ^//import-file +print_requests.py --get > /dev/null 2>&1 || true diff --git a/acceptance/bundle/dms/plan-and-summary/test.toml b/acceptance/bundle/dms/plan-and-summary/test.toml new file mode 100644 index 00000000000..3ca0272e034 --- /dev/null +++ b/acceptance/bundle/dms/plan-and-summary/test.toml @@ -0,0 +1,10 @@ +Ignore = [".databricks"] + +# Verify the state-read path against the deployment metadata service: +# - bundle deploy populates managed_service.json with a deployment_id. +# - bundle plan / summary read state from DMS via ListResources rather than +# from the workspace resources.json file. +# +# Step 4 does not yet ship operation reporting (that is step 5), so the DMS +# resources list is empty after deploy. The test still exercises every +# state-read code path end-to-end. diff --git a/acceptance/bundle/dms/release-lock-error/output.txt b/acceptance/bundle/dms/release-lock-error/output.txt index 476ba422ef5..076bbd86f12 100644 --- a/acceptance/bundle/dms/release-lock-error/output.txt +++ b/acceptance/bundle/dms/release-lock-error/output.txt @@ -2,7 +2,6 @@ >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/dms-release-lock-error/fail-complete/files... Deploying resources... -Updating deployment state... Deployment complete! Warn: Failed to release deployment lock: simulated complete version failure diff --git a/bundle/bundle.go b/bundle/bundle.go index e7eef14b907..ec012b2a031 100644 --- a/bundle/bundle.go +++ b/bundle/bundle.go @@ -141,6 +141,13 @@ type Bundle struct { // (direct only) deployment implementation and state DeploymentBundle direct.DeploymentBundle + // DeploymentID identifies the DMS-side deployment record for this bundle. + // Populated from the workspace managed_service.json during state pull when + // DATABRICKS_BUNDLE_MANAGED_STATE is set, and by the lock package after + // CreateDeployment. Empty when the deployment metadata service is not in + // use, or when DMS is enabled but no prior deployment exists. + DeploymentID string + // if true, we skip approval checks for deploy, destroy resources and delete // files AutoApprove bool diff --git a/bundle/deploy/lock/deployment_metadata_service.go b/bundle/deploy/lock/deployment_metadata_service.go index afb91ef5f79..052c3310cca 100644 --- a/bundle/deploy/lock/deployment_metadata_service.go +++ b/bundle/deploy/lock/deployment_metadata_service.go @@ -14,6 +14,7 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/deploy" + "github.com/databricks/cli/bundle/statemgmt" "github.com/databricks/cli/internal/build" "github.com/databricks/cli/libs/filer" "github.com/databricks/cli/libs/log" @@ -26,18 +27,6 @@ import ( // renews the DMS-side lock lease while a deployment is in progress. const defaultHeartbeatInterval = 30 * time.Second -// managedServiceFileName is the workspace state file where the lock package -// persists the DMS deployment_id across CLI invocations. It is intentionally -// scoped to this package for now; once the state-from-DMS path lands the -// file (and accompanying struct) will move to bundle/statemgmt so both the -// lock and state managers can share it. -const managedServiceFileName = "managed_service.json" - -// managedServiceJSON is the on-disk shape of managedServiceFileName. -type managedServiceJSON struct { - DeploymentID string `json:"deployment_id"` -} - // metadataServiceLock implements DeploymentLock against the bundle deployment // metadata service (DMS). The lock is acquired by creating a new Version // under the deployment; a background goroutine renews the lock lease via @@ -91,6 +80,9 @@ func (l *metadataServiceLock) Acquire(ctx context.Context) error { l.deploymentID = deploymentID l.versionID = versionID + // Publish the deployment ID on the bundle so downstream code (e.g. + // statemgmt.LoadStateFromDMS) can address the right server-side record. + l.b.DeploymentID = deploymentID l.stopHeartbeat = startHeartbeat(ctx, l.svc, deploymentID, versionID) log.Infof(ctx, "Acquired deployment lock: deployment=%s version=%s", deploymentID, versionID) @@ -218,23 +210,23 @@ func resolveDeploymentID(ctx context.Context, b *bundle.Bundle) (string, bool, e return "", false, fmt.Errorf("failed to create state filer: %w", err) } - reader, readErr := f.Read(ctx, managedServiceFileName) + reader, readErr := f.Read(ctx, statemgmt.ManagedServiceFileName) if readErr == nil { defer reader.Close() data, err := io.ReadAll(reader) if err != nil { - return "", false, fmt.Errorf("failed to read %s: %w", managedServiceFileName, err) + return "", false, fmt.Errorf("failed to read %s: %w", statemgmt.ManagedServiceFileName, err) } - var sj managedServiceJSON + var sj statemgmt.ManagedServiceJSON if err := json.Unmarshal(data, &sj); err != nil { - return "", false, fmt.Errorf("failed to parse %s: %w", managedServiceFileName, err) + return "", false, fmt.Errorf("failed to parse %s: %w", statemgmt.ManagedServiceFileName, err) } if sj.DeploymentID != "" { return sj.DeploymentID, false, nil } // File exists but has no deployment_id — treat as fresh. } else if !errors.Is(readErr, fs.ErrNotExist) { - return "", false, fmt.Errorf("failed to read %s: %w", managedServiceFileName, readErr) + return "", false, fmt.Errorf("failed to read %s: %w", statemgmt.ManagedServiceFileName, readErr) } return uuid.New().String(), true, nil @@ -245,13 +237,13 @@ func writeDeploymentID(ctx context.Context, b *bundle.Bundle, deploymentID strin if err != nil { return fmt.Errorf("failed to create state filer: %w", err) } - data, err := json.Marshal(managedServiceJSON{DeploymentID: deploymentID}) + data, err := json.Marshal(statemgmt.ManagedServiceJSON{DeploymentID: deploymentID}) if err != nil { - return fmt.Errorf("failed to marshal %s: %w", managedServiceFileName, err) + return fmt.Errorf("failed to marshal %s: %w", statemgmt.ManagedServiceFileName, err) } - if err := f.Write(ctx, managedServiceFileName, bytes.NewReader(data), + if err := f.Write(ctx, statemgmt.ManagedServiceFileName, bytes.NewReader(data), filer.CreateParentDirectories, filer.OverwriteIfExists); err != nil { - return fmt.Errorf("failed to write %s: %w", managedServiceFileName, err) + return fmt.Errorf("failed to write %s: %w", statemgmt.ManagedServiceFileName, err) } return nil } diff --git a/bundle/env/deployment_metadata.go b/bundle/env/deployment_metadata.go index 004f22c495b..9a25ed14ce3 100644 --- a/bundle/env/deployment_metadata.go +++ b/bundle/env/deployment_metadata.go @@ -16,12 +16,6 @@ import ( // behavior. const managedStateVariable = "DATABRICKS_BUNDLE_MANAGED_STATE" -// ManagedState returns the raw value of DATABRICKS_BUNDLE_MANAGED_STATE if -// set. Callers that only need a bool should use IsManagedState. -func ManagedState(ctx context.Context) (string, bool) { - return get(ctx, []string{managedStateVariable}) -} - // IsManagedState reports whether the DATABRICKS_BUNDLE_MANAGED_STATE // environment variable is set to a truthy value. func IsManagedState(ctx context.Context) bool { diff --git a/bundle/statemgmt/managed_service_json.go b/bundle/statemgmt/managed_service_json.go new file mode 100644 index 00000000000..09d9703d618 --- /dev/null +++ b/bundle/statemgmt/managed_service_json.go @@ -0,0 +1,18 @@ +package statemgmt + +// ManagedServiceFileName is the workspace state-directory file that records the +// deployment metadata service (DMS) deployment_id for the bundle. The file +// pins this bundle to a server-side deployment record across CLI invocations. +// It is created by the lock package after the first CreateDeployment succeeds +// and is read by both the lock package (when re-acquiring the lock) and the +// statemgmt package (when loading resource state from DMS). +// +// resources.json continues to be written by the deploy path so that operators +// who turn DATABRICKS_BUNDLE_MANAGED_STATE off again still have a usable local +// state file. The fallback path is intentional, not accidental. +const ManagedServiceFileName = "managed_service.json" + +// ManagedServiceJSON is the on-disk shape of ManagedServiceFileName. +type ManagedServiceJSON struct { + DeploymentID string `json:"deployment_id"` +} diff --git a/bundle/statemgmt/state_dms.go b/bundle/statemgmt/state_dms.go new file mode 100644 index 00000000000..5804e35bcb1 --- /dev/null +++ b/bundle/statemgmt/state_dms.go @@ -0,0 +1,64 @@ +package statemgmt + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/direct/dstate" + sdkbundle "github.com/databricks/databricks-sdk-go/service/bundle" +) + +// LoadStateFromDMS populates the in-memory DeploymentState DB from the +// deployment metadata service for the deployment identified by +// b.DeploymentID. The state is opened in read mode via OpenWithData; the +// historical local resources.json file is never touched on the DMS path. +// +// The path passed to OpenWithData is the local resources.json path: it is +// only used for diagnostics and as the eventual write target if the deploy +// path later upgrades the state to write mode (step 5 territory; today no +// callers do that under DMS). +// +// When b.DeploymentID is empty the function is a no-op: this is the +// "DMS enabled but no prior deployment" case, where the state is genuinely +// empty until the lock package creates the deployment. +func LoadStateFromDMS(ctx context.Context, b *bundle.Bundle) error { + if b.DeploymentID == "" { + // Initialize an empty state so subsequent reads (e.g. ExportState) + // don't panic on an unopened DB. + _, localPath := b.StateFilenameDirect(ctx) + b.DeploymentBundle.StateDB.OpenWithData(localPath, dstate.NewDatabase("", 0)) + return nil + } + + w := b.WorkspaceClient(ctx) + resources, err := w.Bundle.ListResourcesAll(ctx, sdkbundle.ListResourcesRequest{ + Parent: "deployments/" + b.DeploymentID, + }) + if err != nil { + return fmt.Errorf("failed to list resources from deployment metadata service: %w", err) + } + + data := dstate.NewDatabase("", 0) + for _, r := range resources { + // DMS reports resource keys without the "resources." prefix (e.g. + // "jobs.foo"); the local state DB uses the fully-qualified form + // ("resources.jobs.foo") as its map key, so prepend it here. + stateKey := "resources." + r.ResourceKey + + var stateBytes json.RawMessage + if r.State != nil { + stateBytes = *r.State + } + + data.State[stateKey] = dstate.ResourceEntry{ + ID: r.ResourceId, + State: stateBytes, + } + } + + _, localPath := b.StateFilenameDirect(ctx) + b.DeploymentBundle.StateDB.OpenWithData(localPath, data) + return nil +} diff --git a/bundle/statemgmt/state_dms_test.go b/bundle/statemgmt/state_dms_test.go new file mode 100644 index 00000000000..e1535bd7edf --- /dev/null +++ b/bundle/statemgmt/state_dms_test.go @@ -0,0 +1,100 @@ +package statemgmt + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/databricks/cli/bundle" + "github.com/databricks/cli/bundle/config" + "github.com/databricks/databricks-sdk-go/experimental/mocks" + sdkbundle "github.com/databricks/databricks-sdk-go/service/bundle" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// newTestBundle returns a Bundle wired with a mock workspace client and the +// workspace fields that StateFilenameDirect needs (CurrentUser, RootPath). +func newTestBundle(t *testing.T) (*bundle.Bundle, *mocks.MockWorkspaceClient) { + b := &bundle.Bundle{ + BundleRootPath: t.TempDir(), + Config: config.Root{ + Bundle: config.Bundle{Target: "default"}, + Workspace: config.Workspace{ + StatePath: "/Workspace/Users/me@example.com/.bundle/test/default/state", + }, + }, + } + m := mocks.NewMockWorkspaceClient(t) + b.SetWorkpaceClient(m.WorkspaceClient) + return b, m +} + +// TestLoadStateFromDMS_NoDeploymentID covers the "DMS enabled but the +// workspace has no managed_service.json yet" case: the state DB should be +// initialised empty (no panic on later ExportState) and no API call should be +// made. +func TestLoadStateFromDMS_NoDeploymentID(t *testing.T) { + b, _ := newTestBundle(t) + // b.DeploymentID stays empty. + + err := LoadStateFromDMS(t.Context(), b) + require.NoError(t, err) + + // State is initialised but empty — Export should succeed and return no entries. + got := b.DeploymentBundle.ExportState(t.Context()) + assert.Empty(t, got) +} + +// TestLoadStateFromDMS_PopulatesFromList confirms that resources reported by +// ListResources land in the in-memory state DB under the fully-qualified +// "resources." form, and that per-resource State payloads are preserved +// verbatim. +func TestLoadStateFromDMS_PopulatesFromList(t *testing.T) { + b, m := newTestBundle(t) + b.DeploymentID = "dep-123" + + jobState := json.RawMessage(`{"name":"my-job","max_concurrent_runs":1}`) + mockBundle := m.GetMockBundleAPI() + mockBundle.EXPECT(). + ListResourcesAll(mock.Anything, sdkbundle.ListResourcesRequest{ + Parent: "deployments/dep-123", + }). + Return([]sdkbundle.Resource{ + {ResourceKey: "jobs.foo", ResourceId: "1001", State: &jobState}, + // State omitted — exercises the nil-state path. + {ResourceKey: "pipelines.bar", ResourceId: "p-1"}, + }, nil) + + err := LoadStateFromDMS(t.Context(), b) + require.NoError(t, err) + + job, ok := b.DeploymentBundle.StateDB.GetResourceEntry("resources.jobs.foo") + require.True(t, ok) + assert.Equal(t, "1001", job.ID) + assert.JSONEq(t, string(jobState), string(job.State)) + + pipeline, ok := b.DeploymentBundle.StateDB.GetResourceEntry("resources.pipelines.bar") + require.True(t, ok) + assert.Equal(t, "p-1", pipeline.ID) + assert.Empty(t, pipeline.State) +} + +// TestLoadStateFromDMS_ListError checks that an underlying API failure is +// wrapped and surfaced rather than swallowed (otherwise the deploy would +// proceed against an empty in-memory view and treat everything as a create). +func TestLoadStateFromDMS_ListError(t *testing.T) { + b, m := newTestBundle(t) + b.DeploymentID = "dep-456" + + mockBundle := m.GetMockBundleAPI() + mockBundle.EXPECT(). + ListResourcesAll(mock.Anything, mock.Anything). + Return(nil, errors.New("boom")) + + err := LoadStateFromDMS(t.Context(), b) + require.Error(t, err) + assert.ErrorContains(t, err, "boom") + assert.ErrorContains(t, err, "failed to list resources from deployment metadata service") +} diff --git a/bundle/statemgmt/state_pull.go b/bundle/statemgmt/state_pull.go index 7e62bb84967..8fef9ef5957 100644 --- a/bundle/statemgmt/state_pull.go +++ b/bundle/statemgmt/state_pull.go @@ -16,6 +16,7 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config/engine" "github.com/databricks/cli/bundle/deploy" + "github.com/databricks/cli/bundle/env" "github.com/databricks/cli/libs/diag" "github.com/databricks/cli/libs/filer" "github.com/databricks/cli/libs/log" @@ -219,6 +220,23 @@ func readStates(ctx context.Context, b *bundle.Bundle, alwaysPull AlwaysPull) [] directLocalState := localRead(ctx, localPathDirect, engine.EngineDirect) terraformLocalState := localRead(ctx, localPathTerraform, engine.EngineTerraform) + // When the deployment metadata service is enabled, resource state lives on + // the server. Pull only the deployment-id pointer from the workspace; the + // state itself is fetched later via LoadStateFromDMS. Returning nil here + // causes PullResourcesState to fall through to its "no states found" path, + // which yields an empty StateDesc — exactly what we want, since DMS state + // is loaded separately and does not participate in the local/remote + // lineage-and-serial winner-picking. + if env.IsManagedState(ctx) { + f, err := deploy.StateFiler(ctx, b) + if err != nil { + logdiag.LogError(ctx, err) + return nil + } + b.DeploymentID = readDeploymentID(ctx, f) + return nil + } + if (directLocalState == nil && terraformLocalState == nil) || alwaysPull { f, err := deploy.StateFiler(ctx, b) if err != nil { @@ -294,6 +312,38 @@ func logStatesWarning(ctx context.Context, msg string, states []*StateDesc) { logStatesDiag(ctx, diag.Warning, msg, states) } +// readDeploymentID returns the DMS deployment_id stored in the workspace +// state directory's managed_service.json. An absent file or a missing/empty +// deployment_id is not an error: it means "no prior deployment exists yet" +// and the caller should proceed with an empty state. Read or parse failures +// other than fs.ErrNotExist are logged at debug level and ignored — they are +// recoverable on the next CLI invocation (the lock package will rewrite the +// file after CreateDeployment). +func readDeploymentID(ctx context.Context, f filer.Filer) string { + reader, err := f.Read(ctx, ManagedServiceFileName) + if errors.Is(err, fs.ErrNotExist) { + return "" + } + if err != nil { + log.Debugf(ctx, "Failed to read %s for deployment ID: %v", ManagedServiceFileName, err) + return "" + } + defer reader.Close() + + data, err := io.ReadAll(reader) + if err != nil { + log.Debugf(ctx, "Failed to read %s content: %v", ManagedServiceFileName, err) + return "" + } + + var sj ManagedServiceJSON + if err := json.Unmarshal(data, &sj); err != nil { + log.Debugf(ctx, "Failed to parse %s: %v", ManagedServiceFileName, err) + return "" + } + return sj.DeploymentID +} + func logStatesDiag(ctx context.Context, severity diag.Severity, msg string, states []*StateDesc) { var stateStrs []string for _, state := range states { diff --git a/bundle/statemgmt/state_push.go b/bundle/statemgmt/state_push.go index f098e8a07cc..ce225068d99 100644 --- a/bundle/statemgmt/state_push.go +++ b/bundle/statemgmt/state_push.go @@ -9,6 +9,7 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config/engine" "github.com/databricks/cli/bundle/deploy" + "github.com/databricks/cli/bundle/env" "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/filer" "github.com/databricks/cli/libs/log" @@ -16,7 +17,16 @@ import ( ) // PushResourcesState uploads the local state file to the remote location. +// When the deployment metadata service is enabled the call is a no-op: DMS +// owns resource state on the server, so there is no local state file worth +// uploading. (Step 5 will stop the deploy path from writing local state in +// the first place; until then the local file is written and then ignored.) func PushResourcesState(ctx context.Context, b *bundle.Bundle, engine engine.EngineType) { + if env.IsManagedState(ctx) { + log.Debugf(ctx, "Skipping state push: DATABRICKS_BUNDLE_MANAGED_STATE is set, DMS owns the state") + return + } + f, err := deploy.StateFiler(ctx, b) if err != nil { logdiag.LogError(ctx, err) diff --git a/cmd/bundle/utils/process.go b/cmd/bundle/utils/process.go index 5f43cff6acd..ae9c222eb4f 100644 --- a/cmd/bundle/utils/process.go +++ b/cmd/bundle/utils/process.go @@ -15,6 +15,7 @@ import ( "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/direct" "github.com/databricks/cli/bundle/direct/dstate" + "github.com/databricks/cli/bundle/env" "github.com/databricks/cli/bundle/phases" "github.com/databricks/cli/bundle/statemgmt" "github.com/databricks/cli/cmd/root" @@ -188,10 +189,21 @@ func ProcessBundleRet(cmd *cobra.Command, opts ProcessOptions) (b *bundle.Bundle // Open direct engine state once for all subsequent operations (ExportState, CalculatePlan, Apply, etc.) needDirectState := stateDesc.Engine.IsDirect() && (opts.InitIDs || opts.ErrorOnEmptyState || opts.Deploy || opts.ReadPlanPath != "" || opts.PreDeployChecks || opts.PostStateFunc != nil) if needDirectState { - _, localPath := b.StateFilenameDirect(ctx) - if err := b.DeploymentBundle.StateDB.Open(ctx, localPath, dstate.WithRecovery(true), dstate.WithWrite(false)); err != nil { - logdiag.LogError(ctx, err) - return b, stateDesc, root.ErrAlreadyPrinted + if env.IsManagedState(ctx) { + // Under DMS the StateDB is populated from the server, not from + // the local file. b.DeploymentID may be empty here (no prior + // deployment); LoadStateFromDMS handles that by initialising + // an empty in-memory DB. + if err := statemgmt.LoadStateFromDMS(ctx, b); err != nil { + logdiag.LogError(ctx, err) + return b, stateDesc, root.ErrAlreadyPrinted + } + } else { + _, localPath := b.StateFilenameDirect(ctx) + if err := b.DeploymentBundle.StateDB.Open(ctx, localPath, dstate.WithRecovery(true), dstate.WithWrite(false)); err != nil { + logdiag.LogError(ctx, err) + return b, stateDesc, root.ErrAlreadyPrinted + } } } @@ -233,6 +245,16 @@ func ProcessBundleRet(cmd *cobra.Command, opts ProcessOptions) (b *bundle.Bundle logdiag.LogError(ctx, errors.New("--plan is only supported with direct engine (set bundle.engine to \"direct\" or DATABRICKS_BUNDLE_ENGINE=direct)")) return b, stateDesc, root.ErrAlreadyPrinted } + if env.IsManagedState(ctx) { + // --plan persists a serial/lineage snapshot from the local state + // DB and validates it on apply. Under DMS the server is the + // single source of truth and acquires its own version-based lock + // on CreateVersion, so the local snapshot is meaningless. Reject + // the flag explicitly rather than silently accept it and risk an + // inconsistent deploy. + logdiag.LogError(ctx, errors.New("--plan is not supported with the deployment metadata service")) + return b, stateDesc, root.ErrAlreadyPrinted + } opts.Build = false opts.PreDeployChecks = false