Skip to content

Commit 47fbd29

Browse files
bundle/direct: conditionally upgrade state version when opted into DMS
Normal deploys keep writing state_version 2. When the bundle opts into experimental.record_deployment_history, the deploy upgrades the local state to dmsStateVersion (3) and stamps a new CurrentDmsVersion header field, so future DMS breaking changes or minimum-CLI requirements can be gated on what the state was written with. migrateState now reads up to dmsStateVersion but still auto-migrates only up to the baseline currentStateVersion, so a v3 state is neither auto-applied nor downgraded. The upgrade is applied explicitly in deployCore, never as an automatic migration. Co-authored-by: Isaac
1 parent 30db14f commit 47fbd29

14 files changed

Lines changed: 345 additions & 14 deletions

File tree

acceptance/acceptance_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,13 @@ func testAccept(t *testing.T, inprocessMode bool, singleTest string) int {
277277
cli293Path := DownloadCLI(t, buildDir, "0.293.0")
278278
t.Setenv("CLI_293", cli293Path)
279279
repls.SetPath(cli293Path, "[CLI_293]")
280+
281+
// v1.0.0 understands state schema versions only up to 2. Used by tests
282+
// asserting that an older CLI rejects newer state (e.g. the v3 state
283+
// written when a bundle opts into the deployment metadata service).
284+
cliV1Path := DownloadCLI(t, buildDir, "1.0.0")
285+
t.Setenv("CLI_V1", cliV1Path)
286+
repls.SetPath(cliV1Path, "[CLI_V1]")
280287
}
281288

282289
paths := []string{
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
bundle:
2+
name: test-rdh-state-upgrade
3+
4+
experimental:
5+
record_deployment_history: true
6+
7+
resources:
8+
jobs:
9+
foo:
10+
name: foo-job-renamed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
bundle:
2+
name: test-rdh-state-upgrade
3+
4+
resources:
5+
jobs:
6+
foo:
7+
name: foo-job

acceptance/bundle/deploy/record-deployment-history/state-upgrade/out.test.toml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
2+
=== without the flag: state stays at the baseline schema version
3+
>>> [CLI] bundle deploy
4+
Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-rdh-state-upgrade/default/files...
5+
Deploying resources...
6+
Updating deployment state...
7+
Deployment complete!
8+
9+
>>> print_state.py
10+
{
11+
"state_version": 2,
12+
"current_dms_version": null
13+
}
14+
15+
=== with experimental.record_deployment_history: state upgrades and records the DMS version
16+
>>> [CLI] bundle deploy
17+
Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-rdh-state-upgrade/default/files...
18+
Deploying resources...
19+
Updating deployment state...
20+
Deployment complete!
21+
22+
>>> print_state.py
23+
{
24+
"state_version": 3,
25+
"current_dms_version": 1
26+
}
27+
28+
=== an older CLI (v1.0.0, max state version 2) rejects operations on the upgraded state
29+
>>> errcode [CLI_V1] bundle plan
30+
Warning: unknown field: record_deployment_history
31+
at experimental
32+
in databricks.yml:5:3
33+
34+
Error: migrating state [TEST_TMP_DIR]/.databricks/bundle/default/resources.json: state version 3 is newer than supported version 2; upgrade the CLI
35+
36+
37+
Exit code: 1
38+
39+
=== with the flag, a state written by a newer record_deployment_history version is rejected
40+
>>> update_file.py .databricks/bundle/default/resources.json "current_dms_version": 1 "current_dms_version": 2
41+
42+
>>> errcode [CLI] bundle plan
43+
Error: record_deployment_history state version 2 is newer than supported version 1; upgrade the CLI
44+
45+
46+
Exit code: 1
47+
48+
=== without the flag, the same state is accepted (the check is gated on opt-in)
49+
>>> update_file.py databricks.yml record_deployment_history: true record_deployment_history: false
50+
51+
>>> errcode [CLI] bundle plan
52+
Plan: 0 to add, 0 to change, 0 to delete, 1 unchanged
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# State-version lifecycle for the DMS preview (experimental.record_deployment_history):
2+
# 1. Without the flag, deploys keep writing the baseline state version.
3+
# 2. Opting in upgrades the state version and records the DMS version.
4+
# 3. An older CLI that predates the DMS state version refuses to operate on the
5+
# upgraded state and tells the user to upgrade, so it cannot mishandle it.
6+
# 4. With the flag set, a state stamped with a newer record_deployment_history
7+
# version than this CLI knows is also refused (the state schema version is
8+
# one we support). Without the flag the check is skipped.
9+
10+
title "without the flag: state stays at the baseline schema version"
11+
trace $CLI bundle deploy
12+
trace print_state.py | jq '{state_version, current_dms_version}'
13+
14+
title "with experimental.record_deployment_history: state upgrades and records the DMS version"
15+
# Also renames the job so the deploy writes state (a no-op deploy would not
16+
# rewrite the state file and the upgrade would not be observable yet).
17+
cp databricks.dms.yml databricks.yml
18+
trace $CLI bundle deploy
19+
trace print_state.py | jq '{state_version, current_dms_version}'
20+
21+
title "an older CLI (v1.0.0, max state version 2) rejects operations on the upgraded state"
22+
trace errcode $CLI_V1 bundle plan 2>&1 | contains.py "state version 3 is newer than supported version 2; upgrade the CLI"
23+
24+
title "with the flag, a state written by a newer record_deployment_history version is rejected"
25+
# The state must hold a record_deployment_history version greater than this CLI's
26+
# supported version (currentDmsVersion) to be rejected, so we set supported+1.
27+
# TODO: when currentDmsVersion is bumped, change the value written below to the new
28+
# supported+1. The error's version numbers are not asserted here, so the assertion
29+
# needs no change.
30+
trace update_file.py .databricks/bundle/default/resources.json '"current_dms_version": 1' '"current_dms_version": 2'
31+
trace errcode $CLI bundle plan 2>&1 | contains.py "record_deployment_history state version" "is newer than supported version" "upgrade the CLI"
32+
33+
title "without the flag, the same state is accepted (the check is gated on opt-in)"
34+
trace update_file.py databricks.yml "record_deployment_history: true" "record_deployment_history: false"
35+
trace errcode $CLI bundle plan 2>&1 | contains.py "!is newer than supported version"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Local = true
2+
Cloud = false
3+
4+
# databricks.yml is rewritten in-place by the script (flag added in the second step).
5+
Ignore = [".databricks", "databricks.yml"]
6+
7+
# The state-version upgrade only applies to the direct engine; terraform stores
8+
# state differently and would diverge.
9+
[EnvMatrix]
10+
DATABRICKS_BUNDLE_ENGINE = ["direct"]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
state version 999 is newer than supported version 2; upgrade the CLI
1+
state version 999 is newer than supported version 3; upgrade the CLI
22

33
Exit code: 1

bundle/direct/dstate/migrate.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,25 @@ import (
1010
"github.com/databricks/databricks-sdk-go/service/iam"
1111
)
1212

13-
// migrateState runs all necessary migrations on the database.
13+
// migrateState brings a freshly-loaded state up to a version this CLI can use.
1414
// It is called after loading state from disk.
15+
//
16+
// Two versions are "current" and left untouched: the baseline currentStateVersion
17+
// and the opt-in dmsStateVersion (written when a bundle previews DMS). Legacy
18+
// states below the baseline are migrated forward via the migrations map. A state
19+
// newer than dmsStateVersion was written by a newer CLI, so we refuse it rather
20+
// than risk mishandling a format we don't understand.
21+
//
22+
// The DMS protocol version (current_dms_version) is enforced separately and only
23+
// when the bundle has opted into DMS; see DeploymentState.EnsureSupportedDmsVersion.
1524
func migrateState(db *Database) error {
16-
if db.StateVersion == currentStateVersion {
17-
return nil
18-
}
19-
if db.StateVersion > currentStateVersion {
20-
return fmt.Errorf("state version %d is newer than supported version %d; upgrade the CLI", db.StateVersion, currentStateVersion)
25+
if db.StateVersion > dmsStateVersion {
26+
return fmt.Errorf("state version %d is newer than supported version %d; upgrade the CLI", db.StateVersion, dmsStateVersion)
2127
}
2228

29+
// Only legacy states (below the baseline) migrate here. The DMS upgrade is an
30+
// explicit deploy-time step (see UpgradeToDMS), never an automatic migration,
31+
// so a dmsStateVersion state falls through this loop unchanged.
2332
for version := db.StateVersion; version < currentStateVersion; version++ {
2433
fn, ok := migrations[version]
2534
if !ok {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package dstate
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestMigrateStateLeavesCurrentUntouched(t *testing.T) {
11+
db := &Database{Header: Header{StateVersion: currentStateVersion}}
12+
require.NoError(t, migrateState(db))
13+
assert.Equal(t, currentStateVersion, db.StateVersion)
14+
}
15+
16+
func TestMigrateStateLeavesDMSStateUntouched(t *testing.T) {
17+
// A DMS-upgraded state is already current; it must not be downgraded to the
18+
// baseline version, and the recorded DMS version must be preserved.
19+
db := &Database{Header: Header{StateVersion: dmsStateVersion, CurrentDmsVersion: currentDmsVersion}}
20+
require.NoError(t, migrateState(db))
21+
assert.Equal(t, dmsStateVersion, db.StateVersion)
22+
assert.Equal(t, currentDmsVersion, db.CurrentDmsVersion)
23+
}
24+
25+
func TestMigrateStateRejectsNewerThanSupported(t *testing.T) {
26+
db := &Database{Header: Header{StateVersion: dmsStateVersion + 1}}
27+
err := migrateState(db)
28+
require.Error(t, err)
29+
assert.Contains(t, err.Error(), "upgrade the CLI")
30+
}
31+
32+
// TestStateSchemaVersions pins the state schema version constants. They are part
33+
// of the on-disk format and a contract with older CLIs, so changing them must be
34+
// deliberate. If you are intentionally bumping the baseline schema, this test is
35+
// your checklist: follow the steps below, then update the assertions last.
36+
//
37+
// HOW TO BUMP THE BASELINE STATE VERSION (2 -> 4) AND RETIRE THE DMS SPECIAL-CASING
38+
//
39+
// Version 3 only exists because previewing DMS bumped the schema out-of-band via
40+
// UpgradeToDMS, so non-preview bundles weren't forced to upgrade. The next baseline
41+
// bump is when you delete all of that custom code and make 3 an ordinary version
42+
// in the linear migration chain. Go to 4, not 3 (3 is already in the wild):
43+
//
44+
// 1. state.go: set currentStateVersion = 4. Delete the dmsStateVersion constant
45+
// and the UpgradeToDMS method. Version 3 is now reached only by migration,
46+
// like any other version. (Leave currentDmsVersion and Header.CurrentDmsVersion
47+
// alone; they are a separate concern, see step 4.)
48+
//
49+
// 2. migrate.go: change the upper-bound guard from "> dmsStateVersion" back to
50+
// "> currentStateVersion" (there is again only one current version). Add
51+
// stepwise migrations so every old state climbs to 4:
52+
// migrations[2] = v2 (non-DMS baseline) -> v3
53+
// migrations[3] = v3 (former DMS preview) -> v4
54+
// A v2 state climbs 2->3->4 and a v3 state climbs 3->4, exactly like any
55+
// other version. Write a real transform for whatever the v4 change is.
56+
//
57+
// 3. deploy.go: delete the `if RecordDeploymentHistory { UpgradeToDMS() }` block.
58+
// The version is no longer bumped conditionally; every deploy writes the
59+
// baseline through the normal path.
60+
//
61+
// 4. current_dms_version is NOT part of this bump. It tracks the DMS *protocol*
62+
// version (currentDmsVersion), independent of the state schema version, and is
63+
// enforced by EnsureSupportedDmsVersion (called from cmd/bundle/utils/process.go
64+
// only when the bundle has opted into DMS). Leave the field, the constant, and
65+
// that check in place; if UpgradeToDMS was the only place stamping it, move
66+
// that stamping into the normal write path.
67+
//
68+
// 5. Tests: update the assertions below (the dmsStateVersion assertion and the
69+
// DMS-schema cases go away); add 2->3->4 and 3->4 migration tests.
70+
// TestMigrationsCoverBaseline fails until migrations[2] and migrations[3] exist.
71+
//
72+
// RELATED COVERAGE
73+
// - acceptance/bundle/state/permission_level_migration is a golden migration
74+
// fixture: it commits a real v1 state and asserts the migrated v2 output,
75+
// catching migration-correctness bugs (not just "did you mean to change this").
76+
// - acceptance/bundle/deploy/record-deployment-history/state-upgrade drives the
77+
// full lifecycle, including both rejections (older CLI vs newer state, and a
78+
// newer DMS version vs this CLI).
79+
// - bundle/invariant/continue_293 asserts the current CLI reads state written by
80+
// an older released CLI, so we never break reading older state.
81+
func TestStateSchemaVersions(t *testing.T) {
82+
assert.Equal(t, 2, currentStateVersion)
83+
assert.Equal(t, 3, dmsStateVersion)
84+
assert.Equal(t, 1, currentDmsVersion)
85+
}
86+
87+
// TestMigrationsCoverBaseline guards a baseline bump: every state version below
88+
// currentStateVersion must have a migration to the next version, so migrateState
89+
// can always climb a legacy state up to the baseline. A bump that forgets a
90+
// migration fails here instead of at a user's deploy.
91+
func TestMigrationsCoverBaseline(t *testing.T) {
92+
for v := range currentStateVersion {
93+
assert.Containsf(t, migrations, v, "missing migration for state version %d", v)
94+
}
95+
}

0 commit comments

Comments
 (0)