diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index f6ec0805fb2..733b431f46c 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -277,6 +277,13 @@ func testAccept(t *testing.T, inprocessMode bool, singleTest string) int { cli293Path := DownloadCLI(t, buildDir, "0.293.0") t.Setenv("CLI_293", cli293Path) repls.SetPath(cli293Path, "[CLI_293]") + + // v1.0.0 understands state schema versions only up to 2. Used by tests + // asserting that an older CLI rejects newer state (e.g. the v3 state + // written when a bundle opts into the deployment metadata service). + cliV1Path := DownloadCLI(t, buildDir, "1.0.0") + t.Setenv("CLI_V1", cliV1Path) + repls.SetPath(cliV1Path, "[CLI_V1]") } paths := []string{ diff --git a/acceptance/bundle/deploy/record-deployment-history/state-upgrade/databricks.dms.yml b/acceptance/bundle/deploy/record-deployment-history/state-upgrade/databricks.dms.yml new file mode 100644 index 00000000000..a0803723918 --- /dev/null +++ b/acceptance/bundle/deploy/record-deployment-history/state-upgrade/databricks.dms.yml @@ -0,0 +1,10 @@ +bundle: + name: test-rdh-state-upgrade + +experimental: + record_deployment_history: true + +resources: + jobs: + foo: + name: foo-job-renamed diff --git a/acceptance/bundle/deploy/record-deployment-history/state-upgrade/databricks.yml b/acceptance/bundle/deploy/record-deployment-history/state-upgrade/databricks.yml new file mode 100644 index 00000000000..a721f66492b --- /dev/null +++ b/acceptance/bundle/deploy/record-deployment-history/state-upgrade/databricks.yml @@ -0,0 +1,7 @@ +bundle: + name: test-rdh-state-upgrade + +resources: + jobs: + foo: + name: foo-job diff --git a/acceptance/bundle/deploy/record-deployment-history/state-upgrade/out.test.toml b/acceptance/bundle/deploy/record-deployment-history/state-upgrade/out.test.toml new file mode 100644 index 00000000000..e90b6d5d1ba --- /dev/null +++ b/acceptance/bundle/deploy/record-deployment-history/state-upgrade/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/deploy/record-deployment-history/state-upgrade/output.txt b/acceptance/bundle/deploy/record-deployment-history/state-upgrade/output.txt new file mode 100644 index 00000000000..b499d850d2b --- /dev/null +++ b/acceptance/bundle/deploy/record-deployment-history/state-upgrade/output.txt @@ -0,0 +1,39 @@ + +=== without the flag: no features recorded +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-rdh-state-upgrade/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_state.py +{ + "state_version": 3, + "features": null +} + +=== with experimental.record_deployment_history: the feature is recorded +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/test-rdh-state-upgrade/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> print_state.py +{ + "state_version": 3, + "features": { + "record_deployment_history": "1.2.0" + } +} + +=== an older CLI (v1.0.0, max state version 2) rejects the upgraded state +>>> errcode [CLI_V1] bundle plan +Warning: unknown field: record_deployment_history + at experimental + in databricks.yml:5:3 + +Error: migrating state [TEST_TMP_DIR]/.databricks/bundle/default/resources.json: state version 3 is newer than supported version 2; upgrade the CLI + + +Exit code: 1 diff --git a/acceptance/bundle/deploy/record-deployment-history/state-upgrade/script b/acceptance/bundle/deploy/record-deployment-history/state-upgrade/script new file mode 100644 index 00000000000..bb4d9ec44e9 --- /dev/null +++ b/acceptance/bundle/deploy/record-deployment-history/state-upgrade/script @@ -0,0 +1,19 @@ +# Feature-flag lifecycle for the direct state: +# 1. A normal deploy writes the baseline state version with no features. +# 2. Opting into experimental.record_deployment_history records a feature flag, +# stamped with the CLI version that wrote it. +# 3. An older CLI that predates this state version refuses to operate on it. + +title "without the flag: no features recorded" +trace $CLI bundle deploy +trace print_state.py | jq '{state_version, features}' + +title "with experimental.record_deployment_history: the feature is recorded" +# Also renames the job so the deploy writes state (a no-op deploy would not +# rewrite the state file and the change would not be observable yet). +cp databricks.dms.yml databricks.yml +trace $CLI bundle deploy +trace print_state.py | jq '{state_version, features}' + +title "an older CLI (v1.0.0, max state version 2) rejects the upgraded state" +trace errcode $CLI_V1 bundle plan 2>&1 | contains.py "state version 3 is newer than supported version 2; upgrade the CLI" diff --git a/acceptance/bundle/deploy/record-deployment-history/state-upgrade/test.toml b/acceptance/bundle/deploy/record-deployment-history/state-upgrade/test.toml new file mode 100644 index 00000000000..70992ebb996 --- /dev/null +++ b/acceptance/bundle/deploy/record-deployment-history/state-upgrade/test.toml @@ -0,0 +1,10 @@ +Local = true +Cloud = false + +# databricks.yml is rewritten in-place by the script (flag added in the second step). +Ignore = [".databricks", "databricks.yml"] + +# The state-version upgrade only applies to the direct engine; terraform stores +# state differently and would diverge. +[EnvMatrix] +DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/deploy/wal/chain-3-jobs/output.txt b/acceptance/bundle/deploy/wal/chain-3-jobs/output.txt index f27bfaa3f2c..455af67b3d7 100644 --- a/acceptance/bundle/deploy/wal/chain-3-jobs/output.txt +++ b/acceptance/bundle/deploy/wal/chain-3-jobs/output.txt @@ -12,7 +12,7 @@ Exit code: [KILLED] "cli_version": "[DEV_VERSION]", "lineage": "[UUID]", "serial": 1, - "state_version": 2 + "state_version": 3 } { "k": "resources.jobs.job_01", diff --git a/acceptance/bundle/deploy/wal/crash-after-create/output.txt b/acceptance/bundle/deploy/wal/crash-after-create/output.txt index 2ab926a1dd9..051976b7e72 100644 --- a/acceptance/bundle/deploy/wal/crash-after-create/output.txt +++ b/acceptance/bundle/deploy/wal/crash-after-create/output.txt @@ -13,7 +13,7 @@ Exit code: [KILLED] >>> cat .databricks/bundle/default/resources.json.wal { - "state_version": 2, + "state_version": 3, "cli_version": "[DEV_VERSION]", "lineage": "[UUID]", "serial": 1 diff --git a/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.direct.json b/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.direct.json index 771dd3f908b..c7a30bcd436 100644 --- a/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.direct.json +++ b/acceptance/bundle/deployment/bind/dashboard/recreation/out.state_after_bind.direct.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[DEV_VERSION]", "lineage": "[UUID]", "serial": 2, diff --git a/acceptance/bundle/migrate/basic/out.new_state.json b/acceptance/bundle/migrate/basic/out.new_state.json index c5e2050d921..afb64304a7d 100644 --- a/acceptance/bundle/migrate/basic/out.new_state.json +++ b/acceptance/bundle/migrate/basic/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[DEV_VERSION]", "lineage": "[UUID]", "serial": 6, diff --git a/acceptance/bundle/migrate/dashboards/out.new_state.json b/acceptance/bundle/migrate/dashboards/out.new_state.json index 695d14602a7..a3d491562e0 100644 --- a/acceptance/bundle/migrate/dashboards/out.new_state.json +++ b/acceptance/bundle/migrate/dashboards/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[DEV_VERSION]", "lineage": "[UUID]", "serial": 3, diff --git a/acceptance/bundle/migrate/default-python/out.state_after_migration.json b/acceptance/bundle/migrate/default-python/out.state_after_migration.json index 029649aae3b..8ff25834b78 100644 --- a/acceptance/bundle/migrate/default-python/out.state_after_migration.json +++ b/acceptance/bundle/migrate/default-python/out.state_after_migration.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[DEV_VERSION]", "lineage": "[UUID]", "serial": 5, diff --git a/acceptance/bundle/migrate/grants/out.new_state.json b/acceptance/bundle/migrate/grants/out.new_state.json index 7a24ba0f3c2..5a0a07026ae 100644 --- a/acceptance/bundle/migrate/grants/out.new_state.json +++ b/acceptance/bundle/migrate/grants/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[DEV_VERSION]", "lineage": "[UUID]", "serial": 9, diff --git a/acceptance/bundle/migrate/permissions/out.new_state.json b/acceptance/bundle/migrate/permissions/out.new_state.json index b020b1401d2..c9043ddf6cf 100644 --- a/acceptance/bundle/migrate/permissions/out.new_state.json +++ b/acceptance/bundle/migrate/permissions/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[DEV_VERSION]", "lineage": "[UUID]", "serial": 7, diff --git a/acceptance/bundle/migrate/runas/out.new_state.json b/acceptance/bundle/migrate/runas/out.new_state.json index a7308ab59c2..dc6d571877f 100644 --- a/acceptance/bundle/migrate/runas/out.new_state.json +++ b/acceptance/bundle/migrate/runas/out.new_state.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[DEV_VERSION]", "lineage": "[UUID]", "serial": 5, diff --git a/acceptance/bundle/resources/jobs/big_id/out.state.direct.json b/acceptance/bundle/resources/jobs/big_id/out.state.direct.json index f8cf0ce5bf2..31eb566641c 100644 --- a/acceptance/bundle/resources/jobs/big_id/out.state.direct.json +++ b/acceptance/bundle/resources/jobs/big_id/out.state.direct.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[DEV_VERSION]", "lineage": "[UUID]", "serial": 1, diff --git a/acceptance/bundle/resources/jobs/update/out.state.direct.json b/acceptance/bundle/resources/jobs/update/out.state.direct.json index 785c0c13387..76888ff5266 100644 --- a/acceptance/bundle/resources/jobs/update/out.state.direct.json +++ b/acceptance/bundle/resources/jobs/update/out.state.direct.json @@ -1,5 +1,5 @@ { - "state_version": 2, + "state_version": 3, "cli_version": "[DEV_VERSION]", "lineage": "[UUID]", "serial": 1, diff --git a/acceptance/bundle/state/future_version/output.txt b/acceptance/bundle/state/future_version/output.txt index 7cf98129ee9..0a16971f472 100644 --- a/acceptance/bundle/state/future_version/output.txt +++ b/acceptance/bundle/state/future_version/output.txt @@ -1,3 +1,3 @@ -state version 999 is newer than supported version 2; upgrade the CLI +state version 999 is newer than supported version 3; upgrade the CLI Exit code: 1 diff --git a/acceptance/bundle/state/permission_level_migration/output.txt b/acceptance/bundle/state/permission_level_migration/output.txt index f289e0bc42a..fbcaea775eb 100644 --- a/acceptance/bundle/state/permission_level_migration/output.txt +++ b/acceptance/bundle/state/permission_level_migration/output.txt @@ -12,7 +12,7 @@ Deployment complete! === Print state after deploy >>> print_state.py { - "state_version": 2, + "state_version": 3, "cli_version": "[DEV_VERSION]", "lineage": "test-lineage", "serial": 2, diff --git a/acceptance/bundle/state/unknown_feature/databricks.yml b/acceptance/bundle/state/unknown_feature/databricks.yml new file mode 100644 index 00000000000..5134dbcc12c --- /dev/null +++ b/acceptance/bundle/state/unknown_feature/databricks.yml @@ -0,0 +1,7 @@ +bundle: + name: test-bundle + +resources: + jobs: + my_job: + name: "my job" diff --git a/acceptance/bundle/state/unknown_feature/out.test.toml b/acceptance/bundle/state/unknown_feature/out.test.toml new file mode 100644 index 00000000000..e90b6d5d1ba --- /dev/null +++ b/acceptance/bundle/state/unknown_feature/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/state/unknown_feature/output.txt b/acceptance/bundle/state/unknown_feature/output.txt new file mode 100644 index 00000000000..85321985b81 --- /dev/null +++ b/acceptance/bundle/state/unknown_feature/output.txt @@ -0,0 +1,3 @@ +the deployment state requires feature "future_feature" which this CLI ([DEV_VERSION]) does not support; upgrade to 9.9.9 or newer + +Exit code: 1 diff --git a/acceptance/bundle/state/unknown_feature/resources.future.json b/acceptance/bundle/state/unknown_feature/resources.future.json new file mode 100644 index 00000000000..f3fd6176441 --- /dev/null +++ b/acceptance/bundle/state/unknown_feature/resources.future.json @@ -0,0 +1,10 @@ +{ + "state_version": 3, + "features": { + "future_feature": "9.9.9" + }, + "cli_version": "0.0.0-dev", + "lineage": "test-lineage", + "serial": 1, + "state": {} +} diff --git a/acceptance/bundle/state/unknown_feature/script b/acceptance/bundle/state/unknown_feature/script new file mode 100644 index 00000000000..ef2897b57da --- /dev/null +++ b/acceptance/bundle/state/unknown_feature/script @@ -0,0 +1,6 @@ +mkdir -p .databricks/bundle/default +cp resources.future.json .databricks/bundle/default/resources.json + +# The state records a feature this CLI does not know; it must refuse and point the +# user at the minimum CLI version recorded in the state. +trace $CLI bundle plan 2>&1 | grep -o 'the deployment state requires feature.*' diff --git a/acceptance/bundle/state/unknown_feature/test.toml b/acceptance/bundle/state/unknown_feature/test.toml new file mode 100644 index 00000000000..7fc493d51a4 --- /dev/null +++ b/acceptance/bundle/state/unknown_feature/test.toml @@ -0,0 +1,4 @@ +Ignore = [".databricks"] + +[EnvMatrix] +DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/user_agent/simple/out.requests.deploy.direct.json b/acceptance/bundle/user_agent/simple/out.requests.deploy.direct.json index cc39aad6e9b..127248d5a8d 100644 --- a/acceptance/bundle/user_agent/simple/out.requests.deploy.direct.json +++ b/acceptance/bundle/user_agent/simple/out.requests.deploy.direct.json @@ -222,7 +222,7 @@ "overwrite": "true" }, "body": { - "state_version": 2, + "state_version": 3, "cli_version": "[DEV_VERSION]", "lineage": "[UUID]", "serial": 1, diff --git a/bundle/direct/dstate/migrate.go b/bundle/direct/dstate/migrate.go index 381d63a12eb..52a15feec41 100644 --- a/bundle/direct/dstate/migrate.go +++ b/bundle/direct/dstate/migrate.go @@ -10,12 +10,15 @@ import ( "github.com/databricks/databricks-sdk-go/service/iam" ) -// migrateState runs all necessary migrations on the database. -// It is called after loading state from disk. +// migrateState brings a freshly-loaded state up to the current schema version. +// It is called after loading state from disk. Legacy states below the current +// version are migrated forward via the migrations map; a state newer than the +// current version was written by a newer CLI, so we refuse it rather than risk +// mishandling a format we don't understand. +// +// Feature flags recorded in the state are validated separately; see +// checkSupportedFeatures. func migrateState(db *Database) error { - if db.StateVersion == currentStateVersion { - return nil - } if db.StateVersion > currentStateVersion { return fmt.Errorf("state version %d is newer than supported version %d; upgrade the CLI", db.StateVersion, currentStateVersion) } @@ -39,6 +42,14 @@ func migrateState(db *Database) error { var migrations = map[int]func(*Database) error{ 0: migrateV1ToV2, 1: migrateV1ToV2, + 2: migrateV2ToV3, +} + +// migrateV2ToV3 upgrades to the schema that carries the feature-flag list +// (Header.Features). The list is optional and absent by default, so there is +// nothing to transform. +func migrateV2ToV3(db *Database) error { + return nil } // migrateV1ToV2 migrates permissions and grants entries from the old format diff --git a/bundle/direct/dstate/migrate_test.go b/bundle/direct/dstate/migrate_test.go new file mode 100644 index 00000000000..d8b73f48962 --- /dev/null +++ b/bundle/direct/dstate/migrate_test.go @@ -0,0 +1,75 @@ +package dstate + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMigrateStateLeavesCurrentUntouched(t *testing.T) { + db := &Database{Header: Header{StateVersion: currentStateVersion}} + require.NoError(t, migrateState(db)) + assert.Equal(t, currentStateVersion, db.StateVersion) +} + +func TestMigrateStateUpgradesLegacyToCurrent(t *testing.T) { + // A legacy state migrates forward to the current version (the v2->v3 step adds + // the feature list, which is absent by default). + db := &Database{Header: Header{StateVersion: 2}} + require.NoError(t, migrateState(db)) + assert.Equal(t, currentStateVersion, db.StateVersion) +} + +func TestMigrateStateRejectsNewerThanSupported(t *testing.T) { + db := &Database{Header: Header{StateVersion: currentStateVersion + 1}} + err := migrateState(db) + require.Error(t, err) + assert.Contains(t, err.Error(), "upgrade the CLI") +} + +// TestStateSchemaVersion pins currentStateVersion. It is part of the on-disk +// format and a contract with older CLIs, so changing it must be deliberate. +// +// Bump it only for a structural schema change that older CLIs cannot read: add a +// migration to the migrations map (TestMigrationsCoverBaseline enforces full +// coverage) and update the assertion below. For an additive capability that does +// not change the structure, record a feature flag instead (see knownFeatures and +// RecordFeature) — that lets older CLIs fail with an actionable "upgrade to X" +// message without a version bump. +// +// RELATED COVERAGE +// - acceptance/bundle/state/permission_level_migration: golden v1->v2 migration. +// - acceptance/bundle/state/unknown_feature: a state requiring an unknown feature +// is rejected with the recorded minimum CLI version. +// - bundle/invariant/continue_293: the current CLI reads state written by an +// older released CLI. +func TestStateSchemaVersion(t *testing.T) { + assert.Equal(t, 3, currentStateVersion) +} + +// TestMigrationsCoverBaseline guards a baseline bump: every state version below +// currentStateVersion must have a migration to the next version, so migrateState +// can always climb a legacy state up to the baseline. A bump that forgets a +// migration fails here instead of at a user's deploy. +func TestMigrationsCoverBaseline(t *testing.T) { + for v := range currentStateVersion { + assert.Containsf(t, migrations, v, "missing migration for state version %d", v) + } +} + +func TestCheckSupportedFeatures(t *testing.T) { + // Known features (and none at all) are accepted; an unknown feature is rejected + // with its name and the recorded minimum CLI version. + require.NoError(t, checkSupportedFeatures(&Database{})) + require.NoError(t, checkSupportedFeatures(&Database{Header: Header{ + Features: map[string]string{FeatureRecordDeploymentHistory: "0.0.0-dev"}, + }})) + + err := checkSupportedFeatures(&Database{Header: Header{ + Features: map[string]string{"future_feature": "9.9.9"}, + }}) + require.Error(t, err) + assert.Contains(t, err.Error(), `feature "future_feature"`) + assert.Contains(t, err.Error(), "upgrade to 9.9.9 or newer") +} diff --git a/bundle/direct/dstate/state.go b/bundle/direct/dstate/state.go index 5b2a70adbb3..a130f3aa815 100644 --- a/bundle/direct/dstate/state.go +++ b/bundle/direct/dstate/state.go @@ -10,6 +10,7 @@ import ( "io/fs" "os" "path/filepath" + "slices" "strings" "sync" @@ -21,12 +22,34 @@ import ( ) const ( - currentStateVersion = 2 - initialBufferSize = 64 * 1024 - maxWalEntrySize = 10 * 1024 * 1024 - walSuffix = ".wal" + // currentStateVersion is the schema version written for all direct deployments + // and the target older states are migrated up to on load. Version 3 introduced + // the per-state feature list (Header.Features): from v3 on, additive capabilities + // are recorded as feature flags rather than version bumps, so bump this only for a + // structural change older CLIs cannot read (and add a migration for it). + currentStateVersion = 3 + + initialBufferSize = 64 * 1024 + maxWalEntrySize = 10 * 1024 * 1024 + walSuffix = ".wal" ) +// FeatureRecordDeploymentHistory is recorded when a deploy opts into +// experimental.record_deployment_history. +const FeatureRecordDeploymentHistory = "record_deployment_history" + +// featureMinCLIVersion is the static list of deployment feature flags this CLI +// supports, mapping each to the minimum CLI version required to read a state that +// records it. To add a feature flag, add an entry here. Bumping a value raises the +// version reported to older CLIs the next time a deploy records the feature. +// +// A state recording a feature absent from this list was written by a newer CLI; +// checkSupportedFeatures rejects it and points the user at the version the state +// recorded. +var featureMinCLIVersion = map[string]string{ + FeatureRecordDeploymentHistory: "1.2.0", +} + // errStaleWAL is returned when the WAL serial is behind the expected serial. // The caller should delete the stale WAL and proceed normally. var errStaleWAL = errors.New("stale WAL") @@ -42,10 +65,17 @@ type DeploymentState struct { } type Header struct { - StateVersion int `json:"state_version"` - CLIVersion string `json:"cli_version"` - Lineage string `json:"lineage"` - Serial int `json:"serial"` + StateVersion int `json:"state_version"` + + // Features maps each feature flag this state depends on to the CLI version that + // recorded it. A CLI that does not recognize a feature reports that version so + // the user knows the minimum CLI to upgrade to (see checkSupportedFeatures). + // Empty/omitted for states that use no features. + Features map[string]string `json:"features,omitempty"` + + CLIVersion string `json:"cli_version"` + Lineage string `json:"lineage"` + Serial int `json:"serial"` } type Database struct { @@ -204,6 +234,10 @@ func (db *DeploymentState) Open(ctx context.Context, path string, withRecovery W return fmt.Errorf("migrating state %s: %w", path, err) } + if err := checkSupportedFeatures(&db.Data); err != nil { + return err + } + if withWrite { if err := os.MkdirAll(filepath.Dir(walPath), 0o755); err != nil { return fmt.Errorf("failed to create state directory: %w", err) @@ -221,7 +255,8 @@ func (db *DeploymentState) Open(ctx context.Context, path string, withRecovery W walHead := Header{ Lineage: lineage, Serial: db.Data.Serial + 1, - StateVersion: currentStateVersion, + StateVersion: db.Data.StateVersion, + Features: db.Data.Features, CLIVersion: build.GetInfo().Version, } return appendJSONLine(db.walFile, walHead) @@ -408,12 +443,49 @@ func (db *DeploymentState) UpgradeToWrite() error { walHead := Header{ Lineage: lineage, Serial: db.Data.Serial + 1, - StateVersion: currentStateVersion, + StateVersion: db.Data.StateVersion, + Features: db.Data.Features, CLIVersion: build.GetInfo().Version, } return appendJSONLine(db.walFile, walHead) } +// RecordFeature marks the state as depending on the named feature, stamping the +// minimum CLI version required to read it (from featureMinCLIVersion). It must be +// called before the WAL is started (UpgradeToWrite) so the change is captured in +// the WAL header. +func (db *DeploymentState) RecordFeature(name string) { + minVersion, ok := featureMinCLIVersion[name] + if !ok { + panic(fmt.Sprintf("internal error: unknown feature %q", name)) + } + db.mu.Lock() + defer db.mu.Unlock() + if db.walFile != nil { + panic("internal error: RecordFeature must be called before the state is opened for write") + } + if db.Data.Features == nil { + db.Data.Features = make(map[string]string) + } + db.Data.Features[name] = minVersion +} + +// checkSupportedFeatures rejects a state that records a feature flag this CLI does +// not understand, pointing the user at the minimum CLI version the state recorded. +func checkSupportedFeatures(db *Database) error { + names := make([]string, 0, len(db.Features)) + for name := range db.Features { + names = append(names, name) + } + slices.Sort(names) + for _, name := range names { + if _, ok := featureMinCLIVersion[name]; !ok { + return fmt.Errorf("the deployment state requires feature %q which this CLI (%s) does not support; upgrade to %s or newer", name, build.GetInfo().Version, db.Features[name]) + } + } + return nil +} + func (db *DeploymentState) AssertOpenedForReadOrWrite() { if db.Path == "" { panic("internal error: DeploymentState must be opened first") diff --git a/bundle/direct/dstate/state_test.go b/bundle/direct/dstate/state_test.go index bbfd2559951..148acc5ceaa 100644 --- a/bundle/direct/dstate/state_test.go +++ b/bundle/direct/dstate/state_test.go @@ -1,6 +1,7 @@ package dstate import ( + "encoding/json" "os" "path/filepath" "testing" @@ -32,6 +33,65 @@ func TestOpenSaveFinalizeRoundTrip(t *testing.T) { mustFinalize(t, &db2) } +func TestRecordFeaturePersists(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + + // RecordFeature must run before the WAL is started (UpgradeToWrite), so the + // feature is captured in the WAL header and persisted. + var db DeploymentState + require.NoError(t, db.Open(t.Context(), path, WithRecovery(true), WithWrite(false))) + db.RecordFeature(FeatureRecordDeploymentHistory) + require.NoError(t, db.UpgradeToWrite()) + require.NoError(t, db.SaveState("jobs.my_job", "123", map[string]string{"key": "val"}, nil)) + mustFinalize(t, &db) + + var db2 DeploymentState + require.NoError(t, db2.Open(t.Context(), path, WithRecovery(false), WithWrite(false))) + assert.Equal(t, currentStateVersion, db2.Data.StateVersion) + assert.Equal(t, featureMinCLIVersion[FeatureRecordDeploymentHistory], db2.Data.Features[FeatureRecordDeploymentHistory]) + mustFinalize(t, &db2) +} + +func TestRecordFeaturePanicsAfterWALStarted(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + + var db DeploymentState + require.NoError(t, db.Open(t.Context(), path, WithRecovery(true), WithWrite(true))) + assert.Panics(t, func() { db.RecordFeature(FeatureRecordDeploymentHistory) }) + mustFinalize(t, &db) +} + +func TestOpenRejectsUnknownFeature(t *testing.T) { + // A state recording a feature this CLI does not know is rejected, naming the + // feature and the minimum CLI version the state recorded. + path := filepath.Join(t.TempDir(), "state.json") + data, err := json.Marshal(Database{Header: Header{ + StateVersion: currentStateVersion, + Features: map[string]string{"future_feature": "9.9.9"}, + }}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o600)) + + var db DeploymentState + err = db.Open(t.Context(), path, WithRecovery(true), WithWrite(false)) + require.Error(t, err) + assert.Contains(t, err.Error(), `feature "future_feature"`) + assert.Contains(t, err.Error(), "upgrade to 9.9.9 or newer") + + // A known feature loads fine. + path2 := filepath.Join(t.TempDir(), "state.json") + data, err = json.Marshal(Database{Header: Header{ + StateVersion: currentStateVersion, + Features: map[string]string{FeatureRecordDeploymentHistory: "0.0.0-dev"}, + }}) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path2, data, 0o600)) + + var db2 DeploymentState + require.NoError(t, db2.Open(t.Context(), path2, WithRecovery(true), WithWrite(false))) + mustFinalize(t, &db2) +} + func TestFinalizeWithNoEntriesDoesNotWriteStateFile(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") diff --git a/bundle/phases/deploy.go b/bundle/phases/deploy.go index 15546880b9a..a6feeb90387 100644 --- a/bundle/phases/deploy.go +++ b/bundle/phases/deploy.go @@ -15,6 +15,7 @@ import ( "github.com/databricks/cli/bundle/deploy/terraform" "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/direct" + "github.com/databricks/cli/bundle/direct/dstate" "github.com/databricks/cli/bundle/libraries" "github.com/databricks/cli/bundle/metrics" "github.com/databricks/cli/bundle/permissions" @@ -166,6 +167,12 @@ func Deploy(ctx context.Context, b *bundle.Bundle, outputHandler sync.OutputHand } if engine.IsDirect() { + // Record opted-in features in the state before the WAL is started so they + // are captured in the WAL header (RecordFeature panics if the WAL is already + // open, so it must run before UpgradeToWrite below). + if b.Config.Experimental != nil && b.Config.Experimental.RecordDeploymentHistory { + b.DeploymentBundle.StateDB.RecordFeature(dstate.FeatureRecordDeploymentHistory) + } // Upgrade from read (opened by process.go) to write mode if err := b.DeploymentBundle.StateDB.UpgradeToWrite(); err != nil { logdiag.LogError(ctx, err) diff --git a/cmd/bundle/utils/process.go b/cmd/bundle/utils/process.go index 5f43cff6acd..51d20786ccf 100644 --- a/cmd/bundle/utils/process.go +++ b/cmd/bundle/utils/process.go @@ -189,6 +189,8 @@ func ProcessBundleRet(cmd *cobra.Command, opts ProcessOptions) (b *bundle.Bundle needDirectState := stateDesc.Engine.IsDirect() && (opts.InitIDs || opts.ErrorOnEmptyState || opts.Deploy || opts.ReadPlanPath != "" || opts.PreDeployChecks || opts.PostStateFunc != nil) if needDirectState { _, localPath := b.StateFilenameDirect(ctx) + // Open validates recorded feature flags and rejects state that requires + // a newer CLI (see dstate.checkSupportedFeatures). 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