diff --git a/acceptance/bundle/invariant/no_drift/out.test.toml b/acceptance/bundle/invariant/no_drift/out.test.toml index f68ec6b3111..52747ed599d 100644 --- a/acceptance/bundle/invariant/no_drift/out.test.toml +++ b/acceptance/bundle/invariant/no_drift/out.test.toml @@ -49,4 +49,5 @@ EnvMatrix.INPUT_CONFIG = [ "volume_external.yml.tmpl", "volume_uppercase_name.yml.tmpl" ] +EnvMatrix.LOCAL_DIFF = ["", "--local"] EnvMatrix.READPLAN = ["", "1"] diff --git a/acceptance/bundle/invariant/no_drift/script b/acceptance/bundle/invariant/no_drift/script index 95ecd7cbfd7..19b0855ed16 100644 --- a/acceptance/bundle/invariant/no_drift/script +++ b/acceptance/bundle/invariant/no_drift/script @@ -42,11 +42,13 @@ cat LOG.deploy | contains.py '!panic' '!internal error' > /dev/null # Any failures after this point will be considered as "bug detected" by fuzzer. echo INPUT_CONFIG_OK -# Check both text and JSON plan for no changes +# Check both text and JSON plan for no changes. +# LOCAL_DIFF is either empty or "--local"; with --local the plan ignores the remote +# state of resources, so no drift means the local state saved by deploy matches config. # Note, expect that there maybe more than one resource unchanged -$CLI bundle plan -o json > LOG.planjson 2>LOG.planjson.err +$CLI bundle plan $LOCAL_DIFF -o json > LOG.planjson 2>LOG.planjson.err cat LOG.planjson.err | contains.py '!panic' '!internal error' > /dev/null verify_no_drift.py LOG.planjson -$CLI bundle plan 2>LOG.plan.err | contains.py '!panic' '!internal error' 'Plan: 0 to add, 0 to change, 0 to delete' > LOG.plan +$CLI bundle plan $LOCAL_DIFF 2>LOG.plan.err | contains.py '!panic' '!internal error' 'Plan: 0 to add, 0 to change, 0 to delete' > LOG.plan cat LOG.plan.err | contains.py '!panic' '!internal error' > /dev/null diff --git a/acceptance/bundle/invariant/no_drift/test.toml b/acceptance/bundle/invariant/no_drift/test.toml index ff8a66c196e..9c192724693 100644 --- a/acceptance/bundle/invariant/no_drift/test.toml +++ b/acceptance/bundle/invariant/no_drift/test.toml @@ -1 +1,17 @@ EnvMatrix.READPLAN = ["", "1"] + +# Run every config twice: once planning normally and once with --local, which plans +# using only the local state. The no-drift invariant must hold either way: after a +# deploy the local state saved by the engine must already match the config. +EnvMatrix.LOCAL_DIFF = ["", "--local"] + +# The configs below show drift under --local and are excluded from that variant only. +# They still run in the normal (LOCAL_DIFF="") variant. + +# dashboard, genie_space and vector_search_index persist a remote-sourced value in +# state for drift detection (etag, endpoint_uuid) that the config never carries. +# A normal plan reconciles it against the freshly-read remote; --local has no remote +# to compare against, so the persisted value reads as drift. This is inherent to --local. +EnvMatrixExclude.no_dashboard_local = ["LOCAL_DIFF=--local", "INPUT_CONFIG=dashboard.yml.tmpl"] +EnvMatrixExclude.no_genie_space_local = ["LOCAL_DIFF=--local", "INPUT_CONFIG=genie_space.yml.tmpl"] +EnvMatrixExclude.no_vector_search_index_local = ["LOCAL_DIFF=--local", "INPUT_CONFIG=vector_search_index.yml.tmpl"] diff --git a/acceptance/bundle/local/basic/databricks.yml.tmpl b/acceptance/bundle/local/basic/databricks.yml.tmpl new file mode 100644 index 00000000000..40688ff660d --- /dev/null +++ b/acceptance/bundle/local/basic/databricks.yml.tmpl @@ -0,0 +1,14 @@ +bundle: + name: local-$UNIQUE_NAME + +resources: + jobs: + bar: + name: bar-$UNIQUE_NAME + + foo: + name: foo-$UNIQUE_NAME + tasks: + - task_key: run_bar + run_job_task: + job_id: ${resources.jobs.bar.id} diff --git a/acceptance/bundle/local/basic/out.test.toml b/acceptance/bundle/local/basic/out.test.toml new file mode 100644 index 00000000000..9cfad3fb0d5 --- /dev/null +++ b/acceptance/bundle/local/basic/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/local/basic/output.txt b/acceptance/bundle/local/basic/output.txt new file mode 100644 index 00000000000..2058f51518f --- /dev/null +++ b/acceptance/bundle/local/basic/output.txt @@ -0,0 +1,58 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/local-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] bundle plan --local +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +=== Job reads during 'bundle plan --local': + +>>> print_requests.py --get //jobs/get + +=== Plan --local as JSON: + +>>> [CLI] bundle plan --local -o json +{ + "plan_version": 2, + "cli_version": "[DEV_VERSION]", + "lineage": "[UUID]", + "serial": 1, + "local_only": true, + "plan": { + "resources.jobs.bar": { + "action": "skip" + }, + "resources.jobs.foo": { + "depends_on": [ + { + "node": "resources.jobs.bar", + "label": "${resources.jobs.bar.id}" + } + ], + "action": "skip" + } + } +} + +>>> [CLI] bundle deploy --local +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/local-[UNIQUE_NAME]/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== Telemetry: +local_used true + +=== Destroy +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.jobs.bar + delete resources.jobs.foo + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/local-[UNIQUE_NAME]/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/local/basic/script b/acceptance/bundle/local/basic/script new file mode 100644 index 00000000000..44cd6411b8d --- /dev/null +++ b/acceptance/bundle/local/basic/script @@ -0,0 +1,31 @@ +envsubst '$UNIQUE_NAME' < databricks.yml.tmpl > databricks.yml + +cleanup() { + title "Destroy" + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +# Deploy both jobs so that they are recorded in the local state. foo references +# ${resources.jobs.bar.id}. +trace $CLI bundle deploy +rm -f out.requests.txt + +# A --local plan reports no drift without reading the remote state of the jobs: +# no GET is issued. The ${resources.jobs.bar.id} reference still resolves because +# it comes from the local state, not a remote read. +trace $CLI bundle plan --local +title "Job reads during 'bundle plan --local':\n" +trace print_requests.py --get //jobs/get + +# The JSON plan is self-describing: local_only is true and no entry carries +# remote_state (nothing was fetched), so deploy --plan can warn before applying it. +title "Plan --local as JSON:\n" +trace $CLI bundle plan --local -o json +rm -f out.requests.txt + +# A --local deploy likewise does not read remote state up front and reports its use via telemetry. +trace $CLI bundle deploy --local +title "Telemetry:\n" +print_telemetry_bool_values | grep '^local_used ' diff --git a/acceptance/bundle/local/basic/test.toml b/acceptance/bundle/local/basic/test.toml new file mode 100644 index 00000000000..ebc3f807638 --- /dev/null +++ b/acceptance/bundle/local/basic/test.toml @@ -0,0 +1,7 @@ +Local = true +Cloud = true +RecordRequests = true +# --local skips the per-resource remote read, which only the direct engine performs. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +# databricks.yml is generated at runtime from the template. +Ignore = [".databricks", ".gitignore", "databricks.yml"] diff --git a/acceptance/bundle/local/rejected/databricks.yml b/acceptance/bundle/local/rejected/databricks.yml new file mode 100644 index 00000000000..f3c9c652e4e --- /dev/null +++ b/acceptance/bundle/local/rejected/databricks.yml @@ -0,0 +1,7 @@ +bundle: + name: local-rejected + +resources: + jobs: + my_job: + name: my-job diff --git a/acceptance/bundle/local/rejected/out.test.toml b/acceptance/bundle/local/rejected/out.test.toml new file mode 100644 index 00000000000..65156e0457c --- /dev/null +++ b/acceptance/bundle/local/rejected/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform"] diff --git a/acceptance/bundle/local/rejected/output.txt b/acceptance/bundle/local/rejected/output.txt new file mode 100644 index 00000000000..a51d51b27d3 --- /dev/null +++ b/acceptance/bundle/local/rejected/output.txt @@ -0,0 +1,12 @@ + +>>> [CLI] bundle plan --local +Error: --local is only supported with the direct engine. See https://docs.databricks.com/aws/en/dev-tools/bundles/direct + + +Exit code: 1 + +>>> [CLI] bundle deploy --local +Error: --local is only supported with the direct engine. See https://docs.databricks.com/aws/en/dev-tools/bundles/direct + + +Exit code: 1 diff --git a/acceptance/bundle/local/rejected/script b/acceptance/bundle/local/rejected/script new file mode 100644 index 00000000000..722f6ea3ae3 --- /dev/null +++ b/acceptance/bundle/local/rejected/script @@ -0,0 +1,3 @@ +# --local is only supported by the direct engine; both plan and deploy reject it. +errcode trace $CLI bundle plan --local +errcode trace $CLI bundle deploy --local diff --git a/acceptance/bundle/local/rejected/test.toml b/acceptance/bundle/local/rejected/test.toml new file mode 100644 index 00000000000..0772be31644 --- /dev/null +++ b/acceptance/bundle/local/rejected/test.toml @@ -0,0 +1,2 @@ +# --local is rejected on the terraform engine with an actionable error. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform"] diff --git a/acceptance/bundle/resources/jobs/num_workers/out.test.toml b/acceptance/bundle/resources/jobs/num_workers/out.test.toml index f784a183258..391307d115e 100644 --- a/acceptance/bundle/resources/jobs/num_workers/out.test.toml +++ b/acceptance/bundle/resources/jobs/num_workers/out.test.toml @@ -1,3 +1,4 @@ Local = true Cloud = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] +EnvMatrix.LOCAL = ["", "--local"] diff --git a/acceptance/bundle/resources/jobs/num_workers/script b/acceptance/bundle/resources/jobs/num_workers/script index 8e430e43063..45b9270143b 100644 --- a/acceptance/bundle/resources/jobs/num_workers/script +++ b/acceptance/bundle/resources/jobs/num_workers/script @@ -1,6 +1,6 @@ envsubst < databricks.yml.tmpl > databricks.yml -trace $CLI bundle deploy +trace $CLI bundle deploy $LOCAL trace print_requests.py //jobs -trace $CLI bundle plan +trace $CLI bundle plan $LOCAL rm out.requests.txt diff --git a/acceptance/bundle/resources/jobs/num_workers/test.toml b/acceptance/bundle/resources/jobs/num_workers/test.toml new file mode 100644 index 00000000000..8c3547c70b4 --- /dev/null +++ b/acceptance/bundle/resources/jobs/num_workers/test.toml @@ -0,0 +1,12 @@ +# Also run plan/deploy with --local (direct engine only; rejected on terraform). +# Deploy issues the same mutating requests with or without --local — only the +# remote reads (GETs, excluded from print_requests.py) differ — so the recorded +# output is identical across variants. The " --local" token is stripped from the +# command echo so the >>> lines match the non-local variant. +EnvMatrix.LOCAL = ["", "--local"] +EnvMatrixExclude.no_local_on_terraform = ["LOCAL=--local", "DATABRICKS_BUNDLE_ENGINE=terraform"] +EnvRepl.LOCAL = false + +[[Repls]] +Old = " --local" +New = "" diff --git a/acceptance/bundle/resources/schemas/update/out.test.toml b/acceptance/bundle/resources/schemas/update/out.test.toml index f784a183258..391307d115e 100644 --- a/acceptance/bundle/resources/schemas/update/out.test.toml +++ b/acceptance/bundle/resources/schemas/update/out.test.toml @@ -1,3 +1,4 @@ Local = true Cloud = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] +EnvMatrix.LOCAL = ["", "--local"] diff --git a/acceptance/bundle/resources/schemas/update/script b/acceptance/bundle/resources/schemas/update/script index 733ed7eb9df..6bafac9ae6c 100644 --- a/acceptance/bundle/resources/schemas/update/script +++ b/acceptance/bundle/resources/schemas/update/script @@ -1,12 +1,12 @@ echo "*" > .gitignore -trace $CLI bundle deploy +trace $CLI bundle deploy $LOCAL trace print_requests.py //unity read_state.py schemas schema1 id name catalog_name comment title "Update comment and re-deploy" trace update_file.py databricks.yml COMMENT1 COMMENT2 -trace $CLI bundle deploy +trace $CLI bundle deploy $LOCAL # Why the first time request match for direct & terraform, the requests from second deploy no longer match: # Terraform also sends "enable_predictive_optimization": "INHERIT" which is remote value that it stored in the state. trace print_requests.py //unity | gron.py | grep -v enable_predictive_optimization @@ -14,7 +14,7 @@ read_state.py schemas schema1 id name catalog_name comment title "Restore comment to original value and re-deploy" trace update_file.py databricks.yml COMMENT2 COMMENT1 -trace $CLI bundle deploy +trace $CLI bundle deploy $LOCAL trace print_requests.py //unity | gron.py | grep -v enable_predictive_optimization read_state.py schemas schema1 id name catalog_name comment diff --git a/acceptance/bundle/resources/schemas/update/test.toml b/acceptance/bundle/resources/schemas/update/test.toml new file mode 100644 index 00000000000..8c3547c70b4 --- /dev/null +++ b/acceptance/bundle/resources/schemas/update/test.toml @@ -0,0 +1,12 @@ +# Also run plan/deploy with --local (direct engine only; rejected on terraform). +# Deploy issues the same mutating requests with or without --local — only the +# remote reads (GETs, excluded from print_requests.py) differ — so the recorded +# output is identical across variants. The " --local" token is stripped from the +# command echo so the >>> lines match the non-local variant. +EnvMatrix.LOCAL = ["", "--local"] +EnvMatrixExclude.no_local_on_terraform = ["LOCAL=--local", "DATABRICKS_BUNDLE_ENGINE=terraform"] +EnvRepl.LOCAL = false + +[[Repls]] +Old = " --local" +New = "" diff --git a/acceptance/bundle/resources/volumes/change-name/out.test.toml b/acceptance/bundle/resources/volumes/change-name/out.test.toml index f784a183258..391307d115e 100644 --- a/acceptance/bundle/resources/volumes/change-name/out.test.toml +++ b/acceptance/bundle/resources/volumes/change-name/out.test.toml @@ -1,3 +1,4 @@ Local = true Cloud = false EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] +EnvMatrix.LOCAL = ["", "--local"] diff --git a/acceptance/bundle/resources/volumes/change-name/script b/acceptance/bundle/resources/volumes/change-name/script index ba4d63c4032..8c4b1289d61 100644 --- a/acceptance/bundle/resources/volumes/change-name/script +++ b/acceptance/bundle/resources/volumes/change-name/script @@ -1,4 +1,4 @@ -trace $CLI bundle deploy +trace $CLI bundle deploy $LOCAL trace print_requests.py //unity @@ -8,10 +8,10 @@ $CLI bundle summary -o json | jq .resources title "Update name" trace update_file.py databricks.yml myvolume mynewvolume -trace $CLI bundle plan +trace $CLI bundle plan $LOCAL # terraform marks this as "update", direct marks this as "update_with_id" $CLI bundle plan -o json > out.plan.$DATABRICKS_BUNDLE_ENGINE.json -trace $CLI bundle deploy +trace $CLI bundle deploy $LOCAL trace print_requests.py //unity trace $CLI volumes read main.myschema.mynewvolume diff --git a/acceptance/bundle/resources/volumes/change-name/test.toml b/acceptance/bundle/resources/volumes/change-name/test.toml index bd4b66fe75e..866e7b2f13c 100644 --- a/acceptance/bundle/resources/volumes/change-name/test.toml +++ b/acceptance/bundle/resources/volumes/change-name/test.toml @@ -1,3 +1,17 @@ Ignore = [ ".databricks", ] + +# Also run plan/deploy with --local (direct engine only; rejected on terraform). +# Deploy issues the same mutating requests with or without --local — only the +# remote reads (GETs, excluded from print_requests.py) differ — so the recorded +# output is identical across variants. The " --local" token is stripped from the +# command echo so the >>> lines match the non-local variant. Note: the JSON plan +# (`bundle plan -o json`) keeps no --local: its remote_state would differ. +EnvMatrix.LOCAL = ["", "--local"] +EnvMatrixExclude.no_local_on_terraform = ["LOCAL=--local", "DATABRICKS_BUNDLE_ENGINE=terraform"] +EnvRepl.LOCAL = false + +[[Repls]] +Old = " --local" +New = "" diff --git a/bundle/bundle.go b/bundle/bundle.go index a471a5b9b2e..d695515586b 100644 --- a/bundle/bundle.go +++ b/bundle/bundle.go @@ -156,6 +156,10 @@ type Bundle struct { // When non-empty, only the specified resources are included in deployment. Select []string + // Local, when set via the --local flag, plans and deploys using only the local + // state. The remote state of resources is neither fetched nor considered. + Local bool + // SkipLocalFileValidation makes path translation tolerant of missing local files. // When set, TranslatePaths computes workspace paths without verifying files exist. // Used by config-remote-sync: a user may modify resource paths remotely (e.g., diff --git a/bundle/deployplan/plan.go b/bundle/deployplan/plan.go index 6254e92b802..c7ce6c725d5 100644 --- a/bundle/deployplan/plan.go +++ b/bundle/deployplan/plan.go @@ -16,11 +16,17 @@ import ( const currentPlanVersion = 2 type Plan struct { - PlanVersion int `json:"plan_version,omitempty"` - CLIVersion string `json:"cli_version,omitempty"` - Lineage string `json:"lineage,omitempty"` - Serial int `json:"serial,omitempty"` - Plan map[string]*PlanEntry `json:"plan,omitzero"` + PlanVersion int `json:"plan_version,omitempty"` + CLIVersion string `json:"cli_version,omitempty"` + Lineage string `json:"lineage,omitempty"` + Serial int `json:"serial,omitempty"` + + // LocalOnly is set when the plan was computed with --local, i.e. without + // fetching the remote state of resources. Such a plan can miss out-of-band + // drift, so consumers like "deploy --plan" warn before applying it. + LocalOnly bool `json:"local_only,omitempty"` + + Plan map[string]*PlanEntry `json:"plan,omitzero"` mutex sync.Mutex `json:"-"` lockmap lockmap `json:"-"` diff --git a/bundle/direct/bind.go b/bundle/direct/bind.go index 9760ce95666..fa55701e92c 100644 --- a/bundle/direct/bind.go +++ b/bundle/direct/bind.go @@ -114,7 +114,7 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac os.Remove(tmpStatePath) return nil, err } - plan, err := b.CalculatePlan(ctx, client, configRoot) + plan, err := b.CalculatePlan(ctx, client, configRoot, false) if err != nil { os.Remove(tmpStatePath) return nil, err @@ -170,7 +170,7 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac os.Remove(tmpStatePath) return nil, err } - plan, err = b.CalculatePlan(ctx, client, configRoot) + plan, err = b.CalculatePlan(ctx, client, configRoot, false) if _, ferr := b.StateDB.Finalize(ctx); ferr != nil { log.Warnf(ctx, "failed to finalize state: %v", ferr) } diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index a9527ce56d5..439f4093ea1 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -95,8 +95,12 @@ func (b *DeploymentBundle) InitForApply(ctx context.Context, client *databricks. // CalculatePlan computes the deployment plan by comparing local config against remote state. // StateDB must already be open for read before calling this function. -func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks.WorkspaceClient, configRoot *config.Root) (*deployplan.Plan, error) { +// +// When localOnly is true, the remote state of resources is neither fetched nor considered: +// the plan is computed solely from the difference between the saved local state and the config. +func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks.WorkspaceClient, configRoot *config.Root, localOnly bool) (*deployplan.Plan, error) { b.StateDB.AssertOpenedForRead() + b.localOnly = localOnly err := b.init(client) if err != nil { @@ -108,6 +112,7 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks return nil, fmt.Errorf("reading config: %w", err) } + plan.LocalOnly = localOnly b.Plan = plan g, err := makeGraph(plan) @@ -157,6 +162,12 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks return false } + if localOnly { + // The resource is being deleted because it is absent from config; + // its remote status is irrelevant, so skip the remote read. + return true + } + remoteState, err := retryOnTransient(ctx, func() (any, error) { return adapter.DoRead(ctx, id) }) @@ -214,15 +225,18 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks return false } - remoteState, err := retryOnTransient(ctx, func() (any, error) { - return adapter.DoRead(ctx, dbentry.ID) - }) - if err != nil { - if isResourceGone(err) { - remoteState = nil - } else { - logdiag.LogError(ctx, fmt.Errorf("%s: reading id=%q: %w", errorPrefix, dbentry.ID, err)) - return false + var remoteState any + if !localOnly { + remoteState, err = retryOnTransient(ctx, func() (any, error) { + return adapter.DoRead(ctx, dbentry.ID) + }) + if err != nil { + if isResourceGone(err) { + remoteState = nil + } else { + logdiag.LogError(ctx, fmt.Errorf("%s: reading id=%q: %w", errorPrefix, dbentry.ID, err)) + return false + } } } @@ -260,17 +274,23 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks return false } - if remoteState == nil { + if remoteState == nil && !localOnly { // Even if local action is "recreate" which is higher than "create", we should still pick "create" here // because we know remote does not exist. action = deployplan.Create } else { + // In local-only mode the action is derived purely from the local diff + // (saved state vs config); remote existence is not consulted. action = getMaxAction(entry.Changes) } - // Note, this unconditionally stores remoteState. However, it may updated post-deploy, so whether - // it can be used for variable resolution depends on several factors, see canReadRemoteCache in LookupReferencePreDeploy - b.RemoteStateCache.Store(resourceKey, remoteState) + // Note, remoteState may be updated post-deploy, so whether it can be used for + // variable resolution depends on several factors, see canReadRemoteCache in LookupReferencePreDeploy. + // In --local mode we skip the store entirely (remoteState is nil here): a reference that needs it + // fetches the target on demand via remoteStateForRef, which relies on the cache being absent. + if !localOnly { + b.RemoteStateCache.Store(resourceKey, remoteState) + } // Validate that resources without DoUpdate don't have update actions if action == deployplan.Update && !adapter.HasDoUpdate() { @@ -286,6 +306,23 @@ func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks return nil, errors.New("planning failed") } + // In --local the main loop skips each resource's remote read, but reference + // resolution may still have fetched some targets on demand (remoteStateForRef + // stores them in RemoteStateCache). Surface those reads in the plan. This runs + // after the parallel walk so the write to entry.RemoteState is single-threaded: + // during the walk a target is fetched from a dependent's goroutine under the + // target's read lock, where writing its entry would race with sibling dependents. + if localOnly { + for key, entry := range plan.Plan { + if entry.RemoteState != nil { + continue + } + if remoteState, ok := b.RemoteStateCache.Load(key); ok { + entry.RemoteState = remoteState + } + } + } + for _, entry := range plan.Plan { if entry.Action == deployplan.Skip { entry.NewState = nil @@ -731,12 +768,14 @@ func (b *DeploymentBundle) LookupReferencePreDeploy(ctx context.Context, path *s if configValidErr != nil && remoteValidErr == nil { // The field is only present in remote state schema. if canReadRemoteCache { - remoteState, ok := b.RemoteStateCache.Load(targetResourceKey) + remoteState, ok, err := b.remoteStateForRef(ctx, targetResourceKey, adapter) + if err != nil { + return nil, err + } if ok { return structaccess.Get(remoteState, fieldPath) - } else { - return nil, fmt.Errorf("internal error: no entry in remote state cache for %q (remote-only)", targetResourceKey) } + return nil, fmt.Errorf("internal error: no entry in remote state cache for %q (remote-only)", targetResourceKey) } return nil, errDelayed } @@ -750,17 +789,56 @@ func (b *DeploymentBundle) LookupReferencePreDeploy(ctx context.Context, path *s } if canReadRemoteCache { - remoteState, ok := b.RemoteStateCache.Load(targetResourceKey) + remoteState, ok, err := b.remoteStateForRef(ctx, targetResourceKey, adapter) + if err != nil { + return nil, err + } if ok { return structaccess.Get(remoteState, fieldPath) - } else { - return nil, fmt.Errorf("internal error: no entry in remote state cache for %q", targetResourceKey) } + return nil, fmt.Errorf("internal error: no entry in remote state cache for %q", targetResourceKey) } return nil, errDelayed } +// remoteStateForRef returns the remote state of a referenced target resource for +// reference resolution. Normally the state was cached when the target was processed +// (targets precede their dependents in DAG order). In --local mode that proactive +// read is skipped, so fetch it on demand here: a reference to a remote-only field +// genuinely needs the remote state, so we ignore --local for that target. +// +// The bool reports whether a value is available. In non-local mode a cache miss +// returns (nil, false, nil) and the caller surfaces its own internal error. +func (b *DeploymentBundle) remoteStateForRef(ctx context.Context, targetResourceKey string, adapter *dresources.Adapter) (any, bool, error) { + if remoteState, ok := b.RemoteStateCache.Load(targetResourceKey); ok { + return remoteState, true, nil + } + + if !b.localOnly { + return nil, false, nil + } + + id := b.StateDB.GetResourceID(targetResourceKey) + if id == "" { + return nil, false, fmt.Errorf("internal error: no db entry for %q", targetResourceKey) + } + + remoteState, err := retryOnTransient(ctx, func() (any, error) { + return adapter.DoRead(ctx, id) + }) + if err != nil { + if isResourceGone(err) { + remoteState = nil + } else { + return nil, false, fmt.Errorf("reading id=%q: %w", id, err) + } + } + + b.RemoteStateCache.Store(targetResourceKey, remoteState) + return remoteState, true, nil +} + // resolveReferences processes all references in entry.NewState.Refs. func (b *DeploymentBundle) resolveReferences(ctx context.Context, resourceKey string, entry *deployplan.PlanEntry, errorPrefix string, isPreDeploy bool) bool { sv, ok := b.StateCache.Load(resourceKey) diff --git a/bundle/direct/pkg.go b/bundle/direct/pkg.go index 48a9c5a2ff7..76e1abc0ddc 100644 --- a/bundle/direct/pkg.go +++ b/bundle/direct/pkg.go @@ -44,6 +44,11 @@ type DeploymentBundle struct { Plan *deployplan.Plan RemoteStateCache sync.Map StateCache structvar.Cache + + // localOnly is set by CalculatePlan when planning with --local: the per-resource + // remote read is skipped. A reference that genuinely needs a target's remote state + // fetches it on demand (see remoteStateForRef), so --local is ignored per resource. + localOnly bool } // SetRemoteState updates the remote state with type validation and marks as fresh. diff --git a/bundle/metrics/metrics.go b/bundle/metrics/metrics.go index eadc06cb7dc..044355e6857 100644 --- a/bundle/metrics/metrics.go +++ b/bundle/metrics/metrics.go @@ -10,6 +10,7 @@ const ( ClusterLifecycleStarted = "cluster_lifecycle_started" SqlWarehouseLifecycleStarted = "sql_warehouse_lifecycle_started" SelectUsed = "select_used" + LocalUsed = "local_used" // Whether workspace.state_path is under /Workspace/Shared. StatePathIsShared = "state_path_is_shared" diff --git a/bundle/phases/deploy.go b/bundle/phases/deploy.go index fd76151483c..85961bbd84c 100644 --- a/bundle/phases/deploy.go +++ b/bundle/phases/deploy.go @@ -223,7 +223,7 @@ func Deploy(ctx context.Context, b *bundle.Bundle, outputHandler sync.OutputHand func RunPlan(ctx context.Context, b *bundle.Bundle, engine engine.EngineType) *deployplan.Plan { if engine.IsDirect() { - plan, err := b.DeploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), &b.Config) + plan, err := b.DeploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), &b.Config, b.Local) if err != nil { logdiag.LogError(ctx, err) return nil diff --git a/bundle/phases/destroy.go b/bundle/phases/destroy.go index 74049f26f42..bf5f54294a1 100644 --- a/bundle/phases/destroy.go +++ b/bundle/phases/destroy.go @@ -151,7 +151,7 @@ func Destroy(ctx context.Context, b *bundle.Bundle, engine engine.EngineType) { var plan *deployplan.Plan if engine.IsDirect() { - plan, err = b.DeploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), nil) + plan, err = b.DeploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), nil, false) if err != nil { logdiag.LogError(ctx, err) return diff --git a/bundle/statemgmt/upload_state_for_yaml_sync.go b/bundle/statemgmt/upload_state_for_yaml_sync.go index 6573d1dc56f..af851b7f376 100644 --- a/bundle/statemgmt/upload_state_for_yaml_sync.go +++ b/bundle/statemgmt/upload_state_for_yaml_sync.go @@ -183,7 +183,7 @@ func (m *uploadStateForYamlSync) convertState(ctx context.Context, b *bundle.Bun return false, fmt.Errorf("failed to create uninterpolated config: %w", err) } - plan, err := deploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), &uninterpolatedConfig) + plan, err := deploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), &uninterpolatedConfig, false) if err != nil { return false, err } diff --git a/cmd/bundle/config_remote_sync.go b/cmd/bundle/config_remote_sync.go index 93d9bbb14c3..557ef4abf39 100644 --- a/cmd/bundle/config_remote_sync.go +++ b/cmd/bundle/config_remote_sync.go @@ -87,7 +87,7 @@ Examples: return err } - plan, err := deployBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), &b.Config) + plan, err := deployBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), &b.Config, false) if err != nil { stats.ErrorCategory = protos.BundleConfigRemoteSyncErrorCategoryDetectChangesFailed return fmt.Errorf("failed to detect changes: %w", err) diff --git a/cmd/bundle/deploy.go b/cmd/bundle/deploy.go index 95776eb25f4..8286e9072e7 100644 --- a/cmd/bundle/deploy.go +++ b/cmd/bundle/deploy.go @@ -32,6 +32,7 @@ See https://docs.databricks.com/en/dev-tools/bundles/index.html for more informa var verbose bool var readPlanPath string var selectResources []string + var local bool cmd.Flags().BoolVar(&force, "force", false, "Force-override Git branch validation.") cmd.Flags().BoolVar(&forceLock, "force-lock", false, "Force acquisition of deployment lock.") cmd.Flags().BoolVar(&failOnActiveRuns, "fail-on-active-runs", false, "Fail if there are running jobs or pipelines in the deployment.") @@ -42,6 +43,8 @@ See https://docs.databricks.com/en/dev-tools/bundles/index.html for more informa cmd.Flags().BoolVar(&verbose, "verbose", false, "Enable verbose output.") cmd.Flags().StringVar(&readPlanPath, "plan", "", "Path to a JSON plan file to apply instead of planning (direct engine only).") cmd.Flags().StringSliceVar(&selectResources, "select", nil, "Deploy only the specified resource (e.g. 'my_job' or 'jobs.my_job'). Can be repeated or comma-separated.") + cmd.Flags().BoolVar(&local, "local", false, "Deploy using only the local state, without fetching the remote state of resources.") + cmd.Flags().MarkHidden("local") // Verbose flag currently only affects file sync output, it's used by the vscode extension cmd.Flags().MarkHidden("verbose") @@ -52,6 +55,7 @@ See https://docs.databricks.com/en/dev-tools/bundles/index.html for more informa b.Config.Bundle.Deployment.Lock.Force = forceLock b.AutoApprove = autoApprove b.Select = selectResources + b.Local = local if cmd.Flag("compute-id").Changed { b.Config.Bundle.ClusterId = clusterId diff --git a/cmd/bundle/deployment/migrate.go b/cmd/bundle/deployment/migrate.go index 2d54aafb1ed..53353f5572f 100644 --- a/cmd/bundle/deployment/migrate.go +++ b/cmd/bundle/deployment/migrate.go @@ -245,7 +245,7 @@ To start using direct engine, set "engine: direct" under bundle in your databric return root.ErrAlreadyPrinted } - plan, err := deploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), &b.Config) + plan, err := deploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(ctx), &b.Config, false) if err != nil { return err } diff --git a/cmd/bundle/plan.go b/cmd/bundle/plan.go index 20df8cb5f0f..952de22c8a1 100644 --- a/cmd/bundle/plan.go +++ b/cmd/bundle/plan.go @@ -29,11 +29,14 @@ It is useful for previewing changes before running 'bundle deploy'.`, var force bool var clusterId string var selectResources []string + var local bool cmd.Flags().BoolVar(&force, "force", false, "Force-override Git branch validation.") cmd.Flags().StringVar(&clusterId, "compute-id", "", "Override cluster in the deployment with the given compute ID.") cmd.Flags().StringVarP(&clusterId, "cluster-id", "c", "", "Override cluster in the deployment with the given cluster ID.") cmd.Flags().MarkDeprecated("compute-id", "use --cluster-id instead") cmd.Flags().StringSliceVar(&selectResources, "select", nil, "Plan only the specified resource (e.g. 'my_job' or 'jobs.my_job'). Can be repeated or comma-separated.") + cmd.Flags().BoolVar(&local, "local", false, "Plan using only the local state, without fetching the remote state of resources.") + cmd.Flags().MarkHidden("local") cmd.RunE = func(cmd *cobra.Command, args []string) error { opts := utils.ProcessOptions{ @@ -44,6 +47,7 @@ It is useful for previewing changes before running 'bundle deploy'.`, InitFunc: func(b *bundle.Bundle) { b.Config.Bundle.Force = force b.Select = selectResources + b.Local = local if cmd.Flag("compute-id").Changed { b.Config.Bundle.ClusterId = clusterId diff --git a/cmd/bundle/utils/process.go b/cmd/bundle/utils/process.go index d61c4525530..cff01f952c3 100644 --- a/cmd/bundle/utils/process.go +++ b/cmd/bundle/utils/process.go @@ -15,6 +15,7 @@ import ( "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/direct" "github.com/databricks/cli/bundle/direct/dstate" + "github.com/databricks/cli/bundle/metrics" "github.com/databricks/cli/bundle/phases" "github.com/databricks/cli/bundle/statemgmt" "github.com/databricks/cli/cmd/root" @@ -194,6 +195,15 @@ func ProcessBundleRet(cmd *cobra.Command, opts ProcessOptions) (b *bundle.Bundle return b, stateDesc, root.ErrAlreadyPrinted } + // --local skips the per-resource remote read, which only the direct engine performs. + if b.Local { + if !stateDesc.Engine.IsDirect() { + logdiag.LogError(ctx, errors.New("--local is only supported with the direct engine. See https://docs.databricks.com/aws/en/dev-tools/bundles/direct")) + return b, stateDesc, root.ErrAlreadyPrinted + } + b.Metrics.SetBoolValue(metrics.LocalUsed, true) + } + // Open direct engine state once for all subsequent operations (ExportState, CalculatePlan, Apply, etc.) needDirectState := stateDesc.Engine.IsDirect() && (opts.InitIDs || opts.ErrorOnEmptyState || opts.Deploy || opts.ReadPlanPath != "" || opts.PreDeployChecks || opts.PostStateFunc != nil) if needDirectState { @@ -255,6 +265,9 @@ func ProcessBundleRet(cmd *cobra.Command, opts ProcessOptions) (b *bundle.Bundle if plan.CLIVersion != currentVersion { log.Warnf(ctx, "Plan was created with CLI version %s but current version is %s", plan.CLIVersion, currentVersion) } + if plan.LocalOnly { + log.Warnf(ctx, "Plan was created with --local and does not reflect the remote state of resources; applying it may miss out-of-band drift") + } // Validate that the plan's lineage and serial match the current state // This must happen before any file operations