Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
70efb07
panic on double open
denik Mar 28, 2026
f4c323c
Move state Open/Finalize to top-level callers
denik Mar 28, 2026
cc0542a
Include PreDeployChecks in needDirectState condition
denik Mar 28, 2026
b8021ef
Open state before statemgmt.Load in run.go and generate/dashboard.go
denik Mar 28, 2026
0a7e71c
Add Finalize after Apply in upload_state_for_yaml_sync
denik Mar 28, 2026
bd3b655
Update acceptance test outputs for state lifecycle changes
denik Mar 28, 2026
be25d10
lint fix
denik Mar 28, 2026
f147e7b
update test output
denik Mar 28, 2026
d3f3f35
Fix Finalize retry and migration modified flag in DeploymentState
denik Mar 28, 2026
9d96507
lint fix
denik Mar 28, 2026
a78c4fe
Add deferred Finalize after every Open call
denik Mar 29, 2026
fb09b16
Do not persist state just for migration
denik Mar 29, 2026
773144c
Fix DeleteState dirty tracking and ValidatePlanAgainstState double-open
denik Mar 29, 2026
60179ce
Move Open/Finalize into process.go for destroy, run, config-remote-sync
denik Mar 29, 2026
a3b6366
Remove accidentally committed .cursor/cli.json
denik Mar 29, 2026
87f415d
Fix run.go: defer state loading to PostStateFunc for scripts
denik Mar 29, 2026
4128d0d
Update acceptance test outputs for state lifecycle changes
denik Mar 29, 2026
b38b856
Move run.go Open/Finalize to process.go via AlwaysPull+NeedDirectState
denik Mar 29, 2026
2ddf65d
Split script/resource paths in bundle run to avoid unnecessary state …
denik Mar 29, 2026
bf17973
Revert unrelated run-local-node output and fix NeedDirectState wiring
denik Mar 29, 2026
616b60d
Move resource-run state lifecycle into ProcessBundleRet
denik Mar 29, 2026
de41c55
Only call InitializeURLs when InitIDs is set
denik Mar 29, 2026
426fd94
Fix stale comment in bind.go about CalculatePlan reading from disk
denik Apr 9, 2026
dd2039b
Fix compile error in upload_state_for_yaml_sync.go
denik Apr 9, 2026
925fd26
Remove modified flag and deferred Finalize; keep only structural Open…
denik Apr 9, 2026
de4f3b4
Skip StateDB.Finalize for empty plans in deploy and destroy
denik Apr 10, 2026
2d9a58a
Remove NeedDirectState; use PostStateFunc != nil to infer state needs
denik Apr 10, 2026
1251f0e
Fix alignment of AlwaysPull field in destroy.go
denik Apr 10, 2026
0ae4778
Drop PostStateFunc from shouldReadState; callers already set AlwaysPull
denik Apr 10, 2026
e6313e0
Remove engine-specific detail from PostStateFunc comment
denik Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions bundle/configsync/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,19 @@ func DetectChanges(ctx context.Context, b *bundle.Bundle, engine engine.EngineTy
return nil, fmt.Errorf("state snapshot not available: %w", err)
}

deployBundle := &direct.DeploymentBundle{}
var statePath string
var deployBundle *direct.DeploymentBundle
if engine.IsDirect() {
_, statePath = b.StateFilenameDirect(ctx)
// For direct engine, state is already opened by the caller (process.go).
deployBundle = &b.DeploymentBundle
} else {
_, statePath = b.StateFilenameConfigSnapshot(ctx)
deployBundle = &direct.DeploymentBundle{}
_, statePath := b.StateFilenameConfigSnapshot(ctx)
if err := deployBundle.StateDB.Open(statePath); err != nil {
return nil, fmt.Errorf("failed to open state: %w", err)
}
}

plan, err := deployBundle.CalculatePlan(ctx, b.WorkspaceClient(), &b.Config, statePath)
plan, err := deployBundle.CalculatePlan(ctx, b.WorkspaceClient(), &b.Config)
if err != nil {
return nil, fmt.Errorf("failed to calculate plan: %w", err)
}
Expand Down
9 changes: 4 additions & 5 deletions bundle/deploy/terraform/check_dashboards_modified_remotely.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,12 @@ func collectDashboardsFromState(ctx context.Context, b *bundle.Bundle, directDep
var state ExportedResourcesMap
var err error
if directDeployment {
_, localPath := b.StateFilenameDirect(ctx)
state, err = b.DeploymentBundle.ExportState(ctx, localPath)
state = b.DeploymentBundle.ExportState(ctx)
} else {
state, err = ParseResourcesState(ctx, b)
}
if err != nil {
return nil, err
if err != nil {
return nil, err
}
}

var dashboards []dashboardState
Expand Down
6 changes: 3 additions & 3 deletions bundle/direct/bind.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac
return nil, err
}

// Finalize to write temp state to disk so CalculatePlan can read it
// Finalize to persist temp state to disk
err = b.StateDB.Finalize()
if err != nil {
os.Remove(tmpStatePath)
Expand All @@ -105,7 +105,7 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac
log.Infof(ctx, "Bound %s to id=%s (in temp state)", resourceKey, resourceID)

// First plan + update: populate state with resolved config
plan, err := b.CalculatePlan(ctx, client, configRoot, tmpStatePath)
plan, err := b.CalculatePlan(ctx, client, configRoot)
if err != nil {
os.Remove(tmpStatePath)
return nil, err
Expand Down Expand Up @@ -146,7 +146,7 @@ func (b *DeploymentBundle) Bind(ctx context.Context, client *databricks.Workspac
}

// Second plan: this is the plan to present to the user (change between remote resource and config)
plan, err = b.CalculatePlan(ctx, client, configRoot, tmpStatePath)
plan, err = b.CalculatePlan(ctx, client, configRoot)
if err != nil {
os.Remove(tmpStatePath)
return nil, err
Expand Down
6 changes: 0 additions & 6 deletions bundle/direct/bundle_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,6 @@ func (b *DeploymentBundle) Apply(ctx context.Context, client *databricks.Workspa

return true
})

// This must run even if deploy failed:
err = b.StateDB.Finalize()
if err != nil {
logdiag.LogError(ctx, err)
}
}

func (b *DeploymentBundle) LookupReferencePostDeploy(ctx context.Context, path *structpath.PathNode) (any, error) {
Expand Down
31 changes: 8 additions & 23 deletions bundle/direct/bundle_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"maps"
"os"
"reflect"
"slices"
"strings"
Expand Down Expand Up @@ -42,22 +41,14 @@ func (b *DeploymentBundle) init(client *databricks.WorkspaceClient) error {
// ValidatePlanAgainstState validates that a plan's lineage and serial match the current state.
// This should be called early in the deployment process, before any file operations.
// If the plan has no lineage (first deployment), validation is skipped.
func ValidatePlanAgainstState(statePath string, plan *deployplan.Plan) error {
func ValidatePlanAgainstState(stateDB *dstate.DeploymentState, plan *deployplan.Plan) error {
// If plan has no lineage, this is a first deployment before any state exists
// No validation needed
if plan.Lineage == "" {
return nil
}

var stateDB dstate.DeploymentState
err := stateDB.Open(statePath)
if err != nil {
// If state file doesn't exist but plan has lineage, something is wrong
if os.IsNotExist(err) {
return fmt.Errorf("plan has lineage %q but state file does not exist at %s; the state may have been deleted", plan.Lineage, statePath)
}
return fmt.Errorf("reading state from %s: %w", statePath, err)
}
stateDB.AssertOpened()

// Validate that the plan's lineage matches the current state's lineage
if plan.Lineage != stateDB.Data.Lineage {
Expand All @@ -74,13 +65,10 @@ func ValidatePlanAgainstState(statePath string, plan *deployplan.Plan) error {

// InitForApply initializes the DeploymentBundle for applying a pre-computed plan.
// This is used when --plan is specified to skip the planning phase.
func (b *DeploymentBundle) InitForApply(ctx context.Context, client *databricks.WorkspaceClient, statePath string, plan *deployplan.Plan) error {
err := b.StateDB.Open(statePath)
if err != nil {
return fmt.Errorf("reading state from %s: %w", statePath, err)
}
func (b *DeploymentBundle) InitForApply(ctx context.Context, client *databricks.WorkspaceClient, plan *deployplan.Plan) error {
b.StateDB.AssertOpened()

err = b.init(client)
err := b.init(client)
if err != nil {
return err
}
Expand Down Expand Up @@ -110,13 +98,10 @@ func (b *DeploymentBundle) InitForApply(ctx context.Context, client *databricks.
return nil
}

func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks.WorkspaceClient, configRoot *config.Root, statePath string) (*deployplan.Plan, error) {
err := b.StateDB.Open(statePath)
if err != nil {
return nil, fmt.Errorf("reading state from %s: %w", statePath, err)
}
func (b *DeploymentBundle) CalculatePlan(ctx context.Context, client *databricks.WorkspaceClient, configRoot *config.Root) (*deployplan.Plan, error) {
b.StateDB.AssertOpened()

err = b.init(client)
err := b.init(client)
if err != nil {
return nil, err
}
Expand Down
5 changes: 1 addition & 4 deletions bundle/direct/dstate/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,7 @@ func (db *DeploymentState) Open(path string) error {
defer db.mu.Unlock()

if db.Path != "" {
if db.Path == path {
return nil
}
return fmt.Errorf("already read state %v, cannot open %v", db.Path, path)
panic(fmt.Sprintf("state already opened: %v, cannot open %v", db.Path, path))
}

data, err := os.ReadFile(path)
Expand Down
53 changes: 53 additions & 0 deletions bundle/direct/dstate/state_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package dstate

import (
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestOpenSaveFinalizeRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")

var db DeploymentState
require.NoError(t, db.Open(path))

require.NoError(t, db.SaveState("jobs.my_job", "123", map[string]string{"key": "val"}, nil))
require.NoError(t, db.Finalize())

// Re-open and verify persisted data.
var db2 DeploymentState
require.NoError(t, db2.Open(path))
assert.Equal(t, 1, db2.Data.Serial)
assert.Equal(t, "123", db2.GetResourceID("jobs.my_job"))
}

func TestPanicOnDoubleOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")

var db DeploymentState
require.NoError(t, db.Open(path))

assert.Panics(t, func() {
_ = db.Open(path)
})
}

func TestDeleteState(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")

var db DeploymentState
require.NoError(t, db.Open(path))
require.NoError(t, db.SaveState("jobs.my_job", "123", map[string]string{}, nil))
require.NoError(t, db.Finalize())

require.NoError(t, db.DeleteState("jobs.my_job"))
require.NoError(t, db.Finalize())

var db2 DeploymentState
require.NoError(t, db2.Open(path))
assert.Equal(t, 2, db2.Data.Serial)
assert.Equal(t, "", db2.GetResourceID("jobs.my_job"))
}
9 changes: 3 additions & 6 deletions bundle/direct/pkg.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,7 @@ func (d *DeploymentUnit) SetRemoteState(remoteState any) error {
return nil
}

func (b *DeploymentBundle) ExportState(ctx context.Context, path string) (resourcestate.ExportedResourcesMap, error) {
err := b.StateDB.Open(path)
if err != nil {
return nil, err
}
return b.StateDB.ExportState(ctx), nil
func (b *DeploymentBundle) ExportState(ctx context.Context) resourcestate.ExportedResourcesMap {
b.StateDB.AssertOpened()
return b.StateDB.ExportState(ctx)
}
13 changes: 9 additions & 4 deletions bundle/phases/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ func deployCore(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan, ta

if targetEngine.IsDirect() {
b.DeploymentBundle.Apply(ctx, b.WorkspaceClient(), plan, direct.MigrateMode(false))
// Finalize state: write to disk even if deploy failed, so partial progress is saved.
// Skip for empty plans to avoid creating a state file when nothing was deployed.
if len(plan.Plan) > 0 {
if err := b.DeploymentBundle.StateDB.Finalize(); err != nil {
logdiag.LogError(ctx, err)
}
}
} else {
bundle.ApplyContext(ctx, b, terraform.Apply())
}
Expand Down Expand Up @@ -178,8 +185,7 @@ func Deploy(ctx context.Context, b *bundle.Bundle, outputHandler sync.OutputHand

if plan != nil {
// Initialize DeploymentBundle for applying the loaded plan
_, localPath := b.StateFilenameDirect(ctx)
err := b.DeploymentBundle.InitForApply(ctx, b.WorkspaceClient(), localPath, plan)
err := b.DeploymentBundle.InitForApply(ctx, b.WorkspaceClient(), plan)
if err != nil {
logdiag.LogError(ctx, err)
return
Expand Down Expand Up @@ -214,8 +220,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() {
_, localPath := b.StateFilenameDirect(ctx)
plan, err := b.DeploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(), &b.Config, localPath)
plan, err := b.DeploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(), &b.Config)
if err != nil {
logdiag.LogError(ctx, err)
return nil
Expand Down
9 changes: 7 additions & 2 deletions bundle/phases/destroy.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ func approvalForDestroy(ctx context.Context, b *bundle.Bundle, plan *deployplan.
func destroyCore(ctx context.Context, b *bundle.Bundle, plan *deployplan.Plan, engine engine.EngineType) {
if engine.IsDirect() {
b.DeploymentBundle.Apply(ctx, b.WorkspaceClient(), plan, direct.MigrateMode(false))
// Skip Finalize for empty plans to avoid creating a state file when nothing was destroyed.
if len(plan.Plan) > 0 {
if err := b.DeploymentBundle.StateDB.Finalize(); err != nil {
logdiag.LogError(ctx, err)
}
}
} else {
// Core destructive mutators for destroy. These require informed user consent.
bundle.ApplyContext(ctx, b, terraform.Apply())
Expand Down Expand Up @@ -157,8 +163,7 @@ func Destroy(ctx context.Context, b *bundle.Bundle, engine engine.EngineType) {

var plan *deployplan.Plan
if engine.IsDirect() {
_, localPath := b.StateFilenameDirect(ctx)
plan, err = b.DeploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(), nil, localPath)
plan, err = b.DeploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(), nil)
if err != nil {
logdiag.LogError(ctx, err)
return
Expand Down
6 changes: 1 addition & 5 deletions bundle/statemgmt/check_running_resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,7 @@ func (l *checkRunningResources) Apply(ctx context.Context, b *bundle.Bundle) dia
var state ExportedResourcesMap

if l.engine.IsDirect() {
_, fullPathDirect := b.StateFilenameDirect(ctx)
state, err = b.DeploymentBundle.ExportState(ctx, fullPathDirect)
if err != nil {
return diag.FromErr(err)
}
state = b.DeploymentBundle.ExportState(ctx)
} else {
state, err = terraform.ParseResourcesState(ctx, b)
if err != nil {
Expand Down
6 changes: 1 addition & 5 deletions bundle/statemgmt/state_load.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,7 @@ func (l *load) Apply(ctx context.Context, b *bundle.Bundle) diag.Diagnostics {
var state ExportedResourcesMap

if l.engine.IsDirect() {
_, fullPathDirect := b.StateFilenameDirect(ctx)
state, err = b.DeploymentBundle.ExportState(ctx, fullPathDirect)
if err != nil {
return diag.FromErr(err)
}
state = b.DeploymentBundle.ExportState(ctx)
} else {
var err error
state, err = terraform.ParseResourcesState(ctx, b)
Expand Down
5 changes: 4 additions & 1 deletion bundle/statemgmt/upload_state_for_yaml_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,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(), &uninterpolatedConfig, snapshotPath)
plan, err := deploymentBundle.CalculatePlan(ctx, b.WorkspaceClient(), &uninterpolatedConfig)
if err != nil {
return false, err
}
Expand All @@ -198,6 +198,9 @@ func (m *uploadStateForYamlSync) convertState(ctx context.Context, b *bundle.Bun
}

deploymentBundle.Apply(ctx, b.WorkspaceClient(), plan, direct.MigrateMode(true))
if err := deploymentBundle.StateDB.Finalize(); err != nil {
return false, err
}

return true, nil
}
Expand Down
Loading
Loading