Skip to content

Auto-migrate to direct engine when opted in and dry-run is clean#5858

Merged
pietern merged 21 commits into
mainfrom
denik/engine-migrate-optin
Jul 15, 2026
Merged

Auto-migrate to direct engine when opted in and dry-run is clean#5858
pietern merged 21 commits into
mainfrom
denik/engine-migrate-optin

Conversation

@denik

@denik denik commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Changes

After a successful terraform deploy, if the user opted in via bundle.engine: direct or DATABRICKS_BUNDLE_ENGINE=direct, the CLI now migrates terraform state to direct. The direct state is pushed to the workspace first; local files are renamed only after the workspace has the new state. Failure surfaces as a warning and gets recorded in telemetry; the deploy itself is never affected.

A pre-deploy warning announces the upcoming migration attempt so the user isn't surprised. When the terraform state has no databricks_* resources, the state is swept away (locally + remotely) instead of round-tripped through a conversion, so opt-in bundles with empty state stop hitting the mismatch warning on every deploy.

Split post-deploy telemetry: direct_drymigrate_* still records dry-run outcomes when the user hasn't opted in. New direct_migrate_{error,commit_error,warnings} and direct_migrated_via_{config,env} cover the opt-in path.

Tests

Five new acceptance tests under acceptance/bundle/migrate/:

  • auto-migrate-clean: config opt-in, edit + --plan variant.
  • auto-migrate-envvar: env-var opt-in.
  • auto-migrate-empty-tfstate: empty terraform state gets swept, not converted.
  • auto-migrate-push-failure: fault-injected 403 on the resources.json push leaves local state intact and records direct_migrate_commit_error.
  • auto-migrate-tfbackup-failure: fault-injected 403 on the remote terraform.tfstate delete surfaces a commit error; retry recovers idempotently.

This pull request and its description were written by Isaac.

@denik denik temporarily deployed to test-trigger-is July 8, 2026 11:42 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 8, 2026 11:42 — with GitHub Actions Inactive
@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: 66ac14d

Run: 29366797332

Env 🪲​BUG 🟨​KNOWN 💚​RECOVERED 🙈​SKIP ✅​pass 🙈​skip Time
🪲​ aws linux 3 1 3 4 227 1092 7:04
🪲​ aws windows 3 1 3 4 229 1090 10:19
🪲​ aws-ucws linux 3 1 3 4 313 1009 7:27
🪲​ aws-ucws windows 3 1 3 4 315 1007 11:03
🪲​ azure linux 3 1 3 4 227 1091 7:09
🪲​ azure windows 3 1 3 4 229 1089 10:44
🪲​ azure-ucws linux 3 1 3 4 315 1006 8:47
🪲​ azure-ucws windows 3 1 3 4 317 1004 10:53
🪲​ gcp linux 3 1 3 4 226 1093 6:38
🪲​ gcp windows 3 1 3 4 228 1091 10:33
11 interesting tests: 4 SKIP, 3 BUG, 3 RECOVERED, 1 KNOWN
Test Name aws linux aws windows aws-ucws linux aws-ucws windows azure linux azure windows azure-ucws linux azure-ucws windows gcp linux gcp windows
🟨​ TestAccept 🟨​K 🟨​K 🟨​K 🟨​K 🟨​K 🟨​K 🟨​K 🟨​K 🟨​K 🟨​K
🪲​ TestAccept/bundle/deploy/mlops-stacks 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B
🪲​ TestAccept/bundle/deploy/mlops-stacks/DATABRICKS_BUNDLE_ENGINE=direct 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B
🪲​ TestAccept/bundle/deploy/mlops-stacks/DATABRICKS_BUNDLE_ENGINE=terraform 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B 🪲​B
🙈​ TestAccept/bundle/invariant/no_drift 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/bundle/resources/vector_search_endpoints/drift/recreated_same_name 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/bundle/resources/vector_search_indexes/recreate/embedding_dimension 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
🙈​ TestAccept/ssh/connection 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S 🙈​S
💚​ TestFetchRepositoryInfoAPI_FromRepo 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
💚​ TestFetchRepositoryInfoAPI_FromRepo/root 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
💚​ TestFetchRepositoryInfoAPI_FromRepo/subdir 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R

@denik denik closed this Jul 8, 2026
@denik denik reopened this Jul 8, 2026
@denik denik force-pushed the denik/engine-migrate-optin branch from f385901 to 6c09183 Compare July 8, 2026 20:05
@denik denik temporarily deployed to test-trigger-is July 8, 2026 20:06 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 8, 2026 20:06 — with GitHub Actions Inactive
@denik denik force-pushed the denik/engine-migrate-optin branch from 6c09183 to 135afd7 Compare July 13, 2026 14:43
@denik denik temporarily deployed to test-trigger-is July 13, 2026 14:43 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 14:43 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 14:58 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 14:58 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 15:38 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 15:38 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 16:09 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 16:09 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 16:54 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 16:54 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 18:51 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 18:51 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 19:02 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 19:02 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 19:08 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 19:08 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 19:27 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 19:27 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 20:27 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 13, 2026 20:27 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 14, 2026 19:23 — with GitHub Actions Inactive
denik added 18 commits July 14, 2026 22:41
The previous synchronize event did not fire the build workflow for
some reason; nudge it with an empty commit.

Co-authored-by: Isaac
- Warning when direct is requested and state is terraform now reads
  "Using X engine for deployment, then will try to migrate your state
  to direct engine" so the user knows the state-wins outcome is only
  temporary. Regenerated the golden in state/engine_mismatch.

- "Migrated N resource(s) to direct deployment engine." now reports
  the resource count.

- Emit new bool telemetry keys migrated-via-config / migrated-via-env
  when auto-migration actually rewrites the state, so we can slice by
  opt-in source.

- Test coverage: put the new auto-migrate tests under
  EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] instead of [] so
  they participate in the CI engine-filter; extend auto-migrate-clean
  to end with a no-op deploy, a config edit, and a with/without --plan
  deploy of the resulting update (READPLAN matrix).

Co-authored-by: Isaac
- Rename bundle/statemgmt/check_direct_migration.go to direct_migration.go
  and the top-level function CheckDirectMigration → MigrateToDirect. The
  old name suggested a passive check, but the function now can rewrite
  state when the user opts in.

- Split metrics: DirectDryMigrate{Success,Warnings} now fire ONLY when
  the conversion is truly a dry run (no opt-in). When the user opted in,
  emit the new direct_migrate_error / direct_migrate_commit_error /
  direct_migrate_warnings keys instead, matching the two failure paths
  (state conversion vs. commit) and the warnings case.

Co-authored-by: Isaac
…t.toml

Main switched to fragment-per-PR changelog files under .nextchanges/;
move the auto-migrate entry from NEXT_CHANGELOG.md to a fragment and
regenerate the two new out.test.toml snapshots to pick up the inherited
GOOSOnPR skip.

Co-authored-by: Isaac
- commitMigration: push the direct state to the workspace FIRST, then
  rename local files. Prevents this machine going direct while the
  workspace still has terraform state (which would diverge from other
  clients).
- commitMigration: distinguish "file missing" from a stat error like
  permission denied — only fs.ErrNotExist means we can proceed.
- dryRunMigrate: also SetCollect on the isolated logdiag context so
  transient diagnostics from SecretScopeFixups/BuildStateFromTF don't
  render as user-visible "Error:" lines. Same pattern as YAML sync.
- Move the "will try to migrate" hint out of PullResourcesState and
  into MigrateToDirect. PullResourcesState is shared with commands
  like `bundle debug states` that don't migrate; those were incorrectly
  promising migration. Now the hint appears only when a deploy is
  about to attempt the migration, and includes the opt-in source
  (config vs. env var).
- Auto-migrate tests: assert remote state after migration via
  `bundle debug states --force-pull`, so a broken pushDirectState
  would surface as a test failure instead of hiding behind local files.

Co-authored-by: Isaac
- pushDirectState: after uploading resources.json, back up and delete the
  remote terraform.tfstate inline and return an error on failure. The
  previous code called BackupRemoteTerraformState() which swallows errors
  via log.Warnf, so a workspace could end up with both files
  authoritative and validateStates would later reject that combination.
- commitMigration: distinguish "remote push failed" from "local cache
  update failed after remote push succeeded". Only the first is a real
  commit error; local rename failures are logged as warnings and don't
  break the commit, because the next deploy will pull the direct state
  from the workspace anyway.
- dryRunMigrate: short-circuit when tfState.IDs is empty. A terraform
  state file with no databricks_* resources produces no SaveState calls,
  so Finalize writes nothing to disk, and the subsequent os.Open of the
  temp state fails. Treat that identically to "no terraform state file".
- MigrateToDirect: move the "Migrating state..." announce to right before
  commitMigration is called, so the user only sees it when a commit is
  actually about to happen (not when there's nothing to migrate).

Co-authored-by: Isaac
Three new tests under acceptance/bundle/migrate/:

- auto-migrate-empty-tfstate: seed a terraform.tfstate with no
  databricks_* resources, deploy with bundle.engine=direct, and
  assert auto-migration is skipped (no "Migrating..." message, no
  resources.json produced). Locks in the empty-IDs short-circuit
  fix — previously this would fail at os.Open(tempStatePath).

- auto-migrate-push-failure: fault-inject a 403 on the resources.json
  push and assert commitMigration fails with a clear warning,
  local state stays terraform, and a subsequent deploy recovers
  cleanly. Locks in the push-first ordering — a regression that
  renamed local files before verifying the push would leave a
  stale local direct state after the failure.

- auto-migrate-tfbackup-failure: fault-inject a 403 on the DELETE
  of remote terraform.tfstate that runs after resources.json is
  pushed. Assert the migration surfaces a commit_error warning
  and doesn't rewrite local files. Locks in the "backup fully or
  not at all" contract — previously BackupRemoteTerraformState
  swallowed errors via log.Warnf, so a divergent workspace state
  would ship silently.

Co-authored-by: Isaac
- When bundle.engine=direct is set but the local terraform state has no
  databricks_* resources, remove the terraform state (locally +
  workspace) instead of trying to write an empty resources.json. With no
  state anywhere, the next deploy falls back to engine.Default (direct),
  so the user's opt-in takes effect immediately and the permanent
  "engine mismatch" warning goes away.
- Move EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] from each new
  auto-migrate-*/test.toml into the parent acceptance/bundle/migrate/
  test.toml (was []). The scripts already set the engine inline; the
  outer matrix just controls CI's engine filter, so one value suffices
  and the leaf overrides are redundant. All existing migrate/*
  tests' out.test.toml snapshots regenerate to reflect the new
  inherited value.
- Refactor MigrateToDirect to inspect tfState directly and route the
  three cases (no state, empty state, populated state) to distinct
  code paths; dryRunMigrate now takes the parsed tfState.

Co-authored-by: Isaac
- Gate the empty-state sweep on tfState.Attrs also being empty, not just
  tfState.IDs. IDs is filtered through TerraformToGroupName so an
  unknown managed resource type produces empty IDs even though the
  state has real content; sweeping in that case would destroy state
  the CLI doesn't recognize. Only sweep when the terraform state is
  truly empty (no managed resources at all).
- sweepEmptyTerraformState: return an error on local rename failure
  instead of a warning. Best-effort left a stale local terraform.tfstate
  behind, which the next deploy's PullResourcesState would pick and
  re-enter the mismatch loop. ErrNotExist stays fine — the local file
  may have never been synced.
- Extract recordDryRunNoop helper to remove the duplicate telemetry
  block across the tfState==nil and empty-Attrs branches.
- Assert workspace/delete + terraform.tfstate.backup import in the
  empty-tfstate acceptance test; previously the test only inspected
  local files, so a broken remote sweep would pass silently.

Co-authored-by: Isaac
The old warning "Deployment engine ... does not match the existing state.
Using X engine from the existing state." with a full state-files list was
technically correct but misleading in the auto-migration case: it made the
migration sound like a lost opt-in, when actually we're about to attempt
it. And the state-files list is diagnostic noise for a deploy warning.

- PullResourcesState: suppress the generic mismatch warning for the
  auto-migration case (direct opt-in against terraform state); the
  message and the state-files list belonged to `bundle debug states`,
  not to deploy.
- process.go: after PullResourcesState, print a dedicated one-liner
  when opts.Deploy is set and the auto-migration path applies.
  Non-deploy commands like `bundle debug states` no longer see any
  warning at all — they just show the state files, which is what
  the command is for.
- engine_mismatch acceptance test: update titles/script to reflect
  the new "debug states is quiet" contract.

Co-authored-by: Isaac
The auto-migrate-push-failure and auto-migrate-tfbackup-failure tests care
about the failure path, not about which opt-in mechanism triggered it.
Config-mode opt-in is already covered by auto-migrate-clean; env-var
mode is already covered by auto-migrate-envvar. Switch the fault tests
to DATABRICKS_BUNDLE_ENGINE=direct so their goldens don't carry the
update_file.py yml edit — the opt-in signal is visible on the deploy
command itself.

Co-authored-by: Isaac
- Rename direct auto-migrate success keys from kebab-case to snake_case
  so all seven migration keys share the direct_* prefix:
    migrated-via-config  → direct_migrated_via_config
    migrated-via-env     → direct_migrated_via_env
  A single `grep '^direct_'` now catches every migration bool.
- Add a per-test print_migration_telemetry helper (wrapping the shared
  print_telemetry_bool_values) that reports migration bools and clears
  out.requests.txt so each call sees only the just-completed deploy's
  telemetry. Call it after every deploy in the five auto-migrate tests.
  The goldens now visibly track each transition: dry-run success →
  direct_migrate_commit_error on a faulted push → direct_migrated_via_*
  on the retry.

Co-authored-by: Isaac
- commitMigration: hard-fail (return commit_error) when local rename fails
  after remote push succeeds. Previously we logged a warning and reported
  direct_migrated_via_* success telemetry even though local was left with
  a stale terraform.tfstate — telemetry over-reported. AlwaysPull deploys
  self-heal on the next run, but this machine's next `bundle plan` (which
  also uses AlwaysPull) could see confusing output before that. Aligning
  telemetry with what the user actually sees on this checkout is more
  honest.
- BackupRemoteTerraformState: use filer.OverwriteIfExists when writing the
  .backup path. Without it, a retry after a failed delete leaves the
  .backup already there → Write bails → delete never runs, leaving the
  remote terraform.tfstate to linger forever. The tfbackup-failure test's
  retry now cleans up remote state without the "file already exists"
  warning (which was previously visible in the golden).
- print_migration_telemetry: extract as a shared helper into
  acceptance/bundle/migrate/script.prepare so it's scoped to migrate
  tests only, not global. Callers explicitly `rm -f out.requests.txt`
  after each call because some tests (empty-tfstate) want to keep the
  log for a subsequent print_requests.py assertion.

Co-authored-by: Isaac
The old name implied the "empty state" case was special, but the helper
just backs up terraform state (remote + local) — nothing about it depends
on whether the state is empty. Rename accordingly so a future caller
that needs to back up populated state (e.g. as part of consolidating
commitMigration's inline logic) doesn't feel awkward reusing it.

Also clarify recordAutoMigrateSource: requestedEngine.Type can resolve
to direct from either the config or the env var (config wins in
ResolveEngineSetting); ConfigType is the correct signal for "was this a
durable opt-in?" — env-only opt-ins have ConfigType == EngineNotSet.
Doc-only clarification on the metric-key comment.

Co-authored-by: Isaac
…file

- Rename: the function does a real conversion into a separate location,
  it's not "dry" — the caller decides whether to commit or discard.
- Write the temp state to `resources.migrating.json` alongside the final
  `resources.json` instead of `os.TempDir()/…`. Same directory means
  same filesystem, so commitMigration's os.Rename can't fail with
  EXDEV (tmpfs vs. project disk is the common failure case). Clean up
  stale leftovers from a crashed previous run so UpgradeToWrite's
  O_EXCL open of the .wal doesn't fail.

Co-authored-by: Isaac
Previously ResolveEngineSetting was gated on shouldReadState (Deploy /
AlwaysPull / InitIDs / …). That let commands like `bundle sync`,
`bundle open`, and `bundle validate` silently accept a garbage
DATABRICKS_BUNDLE_ENGINE value because they never called the resolver
— only state-touching commands would surface the error.

Move the call out of the guarded block so every bundle command that
goes through ProcessBundleRet validates the env var. Cost is negligible
(config lookup + env read, no I/O), and the outer-scope declaration
that was there for the same reason is gone too.

Co-authored-by: Isaac
After the sweep, print_requests already proves the workspace API calls
happened, but `bundle debug states --force-pull` shows the full state
landscape (local + remote, terraform + direct). Its empty output
positively asserts that no active terraform.tfstate exists anywhere
and no resources.json was written — a stronger end-to-end proof than
just checking local files or the request log.

Co-authored-by: Isaac
@denik denik force-pushed the denik/engine-migrate-optin branch from e2ea3c2 to 66ac14d Compare July 14, 2026 20:42
@denik denik temporarily deployed to test-trigger-is July 14, 2026 20:43 — with GitHub Actions Inactive
@denik denik temporarily deployed to test-trigger-is July 14, 2026 20:43 — with GitHub Actions Inactive
@pietern pietern temporarily deployed to test-trigger-is July 15, 2026 12:13 — with GitHub Actions Inactive
@pietern pietern temporarily deployed to test-trigger-is July 15, 2026 12:13 — with GitHub Actions Inactive
@pietern pietern enabled auto-merge July 15, 2026 12:59
@pietern pietern added this pull request to the merge queue Jul 15, 2026
Merged via the queue into main with commit 93e9397 Jul 15, 2026
26 checks passed
@pietern pietern deleted the denik/engine-migrate-optin branch July 15, 2026 13:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants