From 75ace5aecb5e4ad4e255c6bd40c8d696ee71cf76 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 28 May 2026 20:59:39 +0200 Subject: [PATCH 01/32] direct: replace WaitAfterCreate/WaitAfterUpdate with Engine callback for intermediate state saves Resources with multi-step deployments (apps, clusters, model serving endpoints, etc.) previously split their logic between DoCreate/DoUpdate and WaitAfterCreate/WaitAfterUpdate at an arbitrary point, making it hard to persist state incrementally. Replace the WaitAfterXxx methods with an *Engine parameter on DoCreate and DoUpdate. Engine.SetID + Engine.SaveState can be called immediately after the initial API call succeeds, before any long-running wait, so the resource is tracked in state even if deployment is interrupted mid-wait (preventing orphaned resources). Simple resources pass _ *Engine and are unaffected. Complex resources (apps, clusters, database instances, model serving endpoints, vector search endpoints and indexes) now inline their wait logic and call engine.SetID/SaveState at the appropriate point. Co-authored-by: Denis Bilenko --- bundle/direct/apply.go | 51 +++------- bundle/direct/dresources/README.md | 4 +- bundle/direct/dresources/adapter.go | 94 +++---------------- bundle/direct/dresources/alert.go | 4 +- bundle/direct/dresources/all_test.go | 19 +--- bundle/direct/dresources/app.go | 33 ++++--- bundle/direct/dresources/app_test.go | 36 +++++-- bundle/direct/dresources/catalog.go | 4 +- bundle/direct/dresources/cluster.go | 81 ++++++++-------- bundle/direct/dresources/dashboard.go | 4 +- bundle/direct/dresources/database_catalog.go | 2 +- bundle/direct/dresources/database_instance.go | 36 +++---- bundle/direct/dresources/engine.go | 47 ++++++++++ bundle/direct/dresources/experiment.go | 4 +- bundle/direct/dresources/external_location.go | 4 +- bundle/direct/dresources/grants.go | 6 +- bundle/direct/dresources/job.go | 4 +- bundle/direct/dresources/model.go | 4 +- .../dresources/model_serving_endpoint.go | 25 ++--- bundle/direct/dresources/permissions.go | 6 +- bundle/direct/dresources/pipeline.go | 4 +- bundle/direct/dresources/postgres_branch.go | 4 +- bundle/direct/dresources/postgres_catalog.go | 2 +- bundle/direct/dresources/postgres_endpoint.go | 4 +- bundle/direct/dresources/postgres_project.go | 4 +- .../dresources/postgres_synced_table.go | 2 +- bundle/direct/dresources/quality_monitor.go | 4 +- bundle/direct/dresources/registered_model.go | 4 +- bundle/direct/dresources/schema.go | 4 +- bundle/direct/dresources/schema_test.go | 6 +- bundle/direct/dresources/secret_scope.go | 2 +- bundle/direct/dresources/secret_scope_acls.go | 4 +- bundle/direct/dresources/sql_warehouse.go | 4 +- .../dresources/synced_database_table.go | 2 +- .../dresources/vector_search_endpoint.go | 20 ++-- .../direct/dresources/vector_search_index.go | 56 +++++------ bundle/direct/dresources/volume.go | 4 +- 37 files changed, 281 insertions(+), 317 deletions(-) create mode 100644 bundle/direct/dresources/engine.go diff --git a/bundle/direct/apply.go b/bundle/direct/apply.go index b3c46036c53..c125bdd4ba1 100644 --- a/bundle/direct/apply.go +++ b/bundle/direct/apply.go @@ -51,6 +51,10 @@ func (d *DeploymentUnit) Deploy(ctx context.Context, db *dstate.DeploymentState, } func (d *DeploymentUnit) Create(ctx context.Context, db *dstate.DeploymentState, newState any) error { + engine := dresources.NewEngine(d.Adapter.StateType(), func(id string, x any) error { + return db.SaveState(d.ResourceKey, id, x, d.DependsOn) + }) + var newID string var remoteState any _, err := retryWith(ctx, func(err error) bool { @@ -59,7 +63,7 @@ func (d *DeploymentUnit) Create(ctx context.Context, db *dstate.DeploymentState, return ok && isTransient(ctx, err) }, func() (struct{}, error) { var e error - newID, remoteState, e = d.Adapter.DoCreate(ctx, newState) + newID, remoteState, e = d.Adapter.DoCreate(ctx, engine, newState) return struct{}{}, e }) err = dresources.UnwrapRetrySafe(err) @@ -80,18 +84,6 @@ func (d *DeploymentUnit) Create(ctx context.Context, db *dstate.DeploymentState, return fmt.Errorf("saving state after creating id=%s: %w", newID, err) } - waitRemoteState, err := retryOnTransient(ctx, func() (any, error) { - return d.Adapter.WaitAfterCreate(ctx, newID, newState) - }) - if err != nil { - return fmt.Errorf("waiting after creating id=%s: %w", newID, err) - } - - err = d.SetRemoteState(waitRemoteState) - if err != nil { - return err - } - return nil } @@ -137,8 +129,13 @@ func (d *DeploymentUnit) Update(ctx context.Context, db *dstate.DeploymentState, return fmt.Errorf("internal error: DoUpdate not implemented for resource %s", d.ResourceKey) } + engine := dresources.NewEngine(d.Adapter.StateType(), func(_ string, x any) error { + return db.SaveState(d.ResourceKey, id, x, d.DependsOn) + }) + engine.SetID(id) + remoteState, err := retryOnTransient(ctx, func() (any, error) { - return d.Adapter.DoUpdate(ctx, id, newState, planEntry) + return d.Adapter.DoUpdate(ctx, engine, id, newState, planEntry) }) if err != nil { return fmt.Errorf("updating id=%s: %w", id, err) @@ -154,19 +151,6 @@ func (d *DeploymentUnit) Update(ctx context.Context, db *dstate.DeploymentState, return fmt.Errorf("saving state id=%s: %w", id, err) } - waitRemoteState, err := retryOnTransient(ctx, func() (any, error) { - return d.Adapter.WaitAfterUpdate(ctx, id, newState) - }) - if err != nil { - return fmt.Errorf("waiting after updating id=%s: %w", id, err) - } - - // Update remote state with the result from wait operation - err = d.SetRemoteState(waitRemoteState) - if err != nil { - return err - } - return nil } @@ -198,19 +182,6 @@ func (d *DeploymentUnit) UpdateWithID(ctx context.Context, db *dstate.Deployment return fmt.Errorf("saving state id=%s: %w", oldID, err) } - waitRemoteState, err := retryOnTransient(ctx, func() (any, error) { - return d.Adapter.WaitAfterUpdate(ctx, newID, newState) - }) - if err != nil { - return fmt.Errorf("waiting after updating id=%s: %w", newID, err) - } - - // Update remote state with the result from wait operation - err = d.SetRemoteState(waitRemoteState) - if err != nil { - return err - } - return nil } diff --git a/bundle/direct/dresources/README.md b/bundle/direct/dresources/README.md index 472631f0e38..1c4c37ff957 100644 --- a/bundle/direct/dresources/README.md +++ b/bundle/direct/dresources/README.md @@ -34,9 +34,9 @@ Do **not** derive update mask field names from `entry.Changes`. The paths in `en If a resource has fields that must not be sent in updates (deploy-only, lifecycle-only, etc.), document them explicitly with a `var` block and a comment explaining each exclusion. -## Async APIs: WaitAfterCreate / WaitAfterUpdate +## Async APIs -For resources whose create or update is asynchronous (the resource is not immediately ready after the call returns), implement `WaitAfterCreate` and/or `WaitAfterUpdate` instead of polling inline inside DoCreate/DoUpdate. These are the correct extension points in the framework, and polling inline bypasses state persistence timing. +For resources whose create or update is asynchronous, poll inline inside `DoCreate`/`DoUpdate` after the initial API call. To prevent orphaning if deployment is interrupted during a long wait, call `engine.SetID(id)` then `engine.SaveState(config)` immediately after the resource is created and before any waiting. The framework provides a `*Engine` as the second argument to both methods. ## Slice ordering: KeyedSlices diff --git a/bundle/direct/dresources/adapter.go b/bundle/direct/dresources/adapter.go index fdaa15bfcea..ba68692722c 100644 --- a/bundle/direct/dresources/adapter.go +++ b/bundle/direct/dresources/adapter.go @@ -53,13 +53,14 @@ type IResource interface { // DoCreate creates a new resource from the newState. Returns id of the resource and optionally remote state. // If remote state is available as part of the operation, return it; otherwise return nil. - // Example: func (r *ResourceVolume) DoCreate(ctx context.Context, newState *catalog.CreateVolumeRequestContent) (string, *catalog.VolumeInfo, error) - DoCreate(ctx context.Context, newState any) (id string, remoteState any, e error) + // Call engine.SetID then engine.SaveState to persist intermediate state before long-running waits. + // Example: func (r *ResourceVolume) DoCreate(ctx context.Context, _ *Engine, newState *catalog.CreateVolumeRequestContent) (string, *catalog.VolumeInfo, error) + DoCreate(ctx context.Context, engine *Engine, newState any) (id string, remoteState any, e error) // [Optional] DoUpdate updates the resource. ID must not change as a result of this operation. Returns optionally remote state. // If remote state is available as part of the operation, return it; otherwise return nil. - // Example: func (r *ResourceSchema) DoUpdate(ctx context.Context, id string, newState *catalog.CreateSchema, entry *PlanEntry) (*catalog.SchemaInfo, error) - DoUpdate(ctx context.Context, id string, newState any, entry *PlanEntry) (remoteState any, e error) + // Example: func (r *ResourceSchema) DoUpdate(ctx context.Context, _ *Engine, id string, newState *catalog.CreateSchema, entry *PlanEntry) (*catalog.SchemaInfo, error) + DoUpdate(ctx context.Context, engine *Engine, id string, newState any, entry *PlanEntry) (remoteState any, e error) // [Optional] DoUpdateWithID performs an update that may result in resource having a new ID. Returns new id and optionally remote state. DoUpdateWithID(ctx context.Context, id string, newState any) (newID string, remoteState any, e error) @@ -67,13 +68,6 @@ type IResource interface { // [Optional] DoResize resizes the resource. Only supported by clusters DoResize(ctx context.Context, id string, newState any, entry *PlanEntry) error - // [Optional] WaitAfterCreate waits for the resource to become ready after creation. Returns optionally updated remote state. - // TODO: wait status should be persisted in the state. - WaitAfterCreate(ctx context.Context, id string, newState any) (remoteState any, e error) - - // [Optional] WaitAfterUpdate waits for the resource to become ready after update. Returns optionally updated remote state. - WaitAfterUpdate(ctx context.Context, id string, newState any) (remoteState any, e error) - // [Optional] WaitAfterDelete waits for the resource to be fully removed after DoDelete returns. // Useful for backends with asynchronous deletion: a follow-up create on the same name (recreate path) // would otherwise race with the in-progress teardown. State is dropped before this is called, so a @@ -105,8 +99,6 @@ type Adapter struct { // Optional: doUpdate *calladapt.BoundCaller doUpdateWithID *calladapt.BoundCaller - waitAfterCreate *calladapt.BoundCaller - waitAfterUpdate *calladapt.BoundCaller waitAfterDelete *calladapt.BoundCaller overrideChangeDesc *calladapt.BoundCaller doResize *calladapt.BoundCaller @@ -139,8 +131,6 @@ func NewAdapter(typedNil any, resourceType string, client *databricks.WorkspaceC doUpdate: nil, doUpdateWithID: nil, doResize: nil, - waitAfterCreate: nil, - waitAfterUpdate: nil, waitAfterDelete: nil, overrideChangeDesc: nil, isGone: nil, @@ -215,16 +205,6 @@ func (a *Adapter) initMethods(resource any) error { return err } - a.waitAfterCreate, err = calladapt.PrepareCall(resource, reflect.TypeFor[IResource](), "WaitAfterCreate") - if err != nil { - return err - } - - a.waitAfterUpdate, err = calladapt.PrepareCall(resource, reflect.TypeFor[IResource](), "WaitAfterUpdate") - if err != nil { - return err - } - a.waitAfterDelete, err = calladapt.PrepareCall(resource, reflect.TypeFor[IResource](), "WaitAfterDelete") if err != nil { return err @@ -292,7 +272,7 @@ func (a *Adapter) validate() error { validations := []any{ "PrepareState return", a.prepareState.OutTypes[0], stateType, - "DoCreate newState", a.doCreate.InTypes[1], stateType, + "DoCreate newState", a.doCreate.InTypes[2], stateType, "DoDelete state", a.doDelete.InTypes[2], stateType, } @@ -315,7 +295,7 @@ func (a *Adapter) validate() error { // Validate DoUpdate: must return (remoteType, error) if implemented if a.doUpdate != nil { - validations = append(validations, "DoUpdate newState", a.doUpdate.InTypes[2], stateType) + validations = append(validations, "DoUpdate newState", a.doUpdate.InTypes[3], stateType) if len(a.doUpdate.OutTypes) != 2 { return fmt.Errorf("DoUpdate must return (remoteType, error), got %d return values", len(a.doUpdate.OutTypes)) } @@ -339,24 +319,6 @@ func (a *Adapter) validate() error { validations = append(validations, "DoUpdateWithID remoteState return", a.doUpdateWithID.OutTypes[1], remoteType) } - if a.waitAfterCreate != nil { - validations = append(validations, "WaitAfterCreate newState", a.waitAfterCreate.InTypes[2], stateType) - // WaitAfterCreate must return (remoteType, error) - if len(a.waitAfterCreate.OutTypes) != 2 { - return fmt.Errorf("WaitAfterCreate must return (remoteType, error), got %d return values", len(a.waitAfterCreate.OutTypes)) - } - validations = append(validations, "WaitAfterCreate remoteState return", a.waitAfterCreate.OutTypes[0], remoteType) - } - - if a.waitAfterUpdate != nil { - validations = append(validations, "WaitAfterUpdate newState", a.waitAfterUpdate.InTypes[2], stateType) - // WaitAfterUpdate must return (remoteType, error) - if len(a.waitAfterUpdate.OutTypes) != 2 { - return fmt.Errorf("WaitAfterUpdate must return (remoteType, error), got %d return values", len(a.waitAfterUpdate.OutTypes)) - } - validations = append(validations, "WaitAfterUpdate remoteState return", a.waitAfterUpdate.OutTypes[0], remoteType) - } - err = validateTypes(validations...) if err != nil { return err @@ -459,8 +421,8 @@ func normalizeNilPointer(v any) any { return v } -func (a *Adapter) DoCreate(ctx context.Context, newState any) (string, any, error) { - outs, err := a.doCreate.Call(ctx, newState) +func (a *Adapter) DoCreate(ctx context.Context, engine *Engine, newState any) (string, any, error) { + outs, err := a.doCreate.Call(ctx, engine, newState) if err != nil { return "", nil, err } @@ -477,12 +439,12 @@ func (a *Adapter) HasDoUpdate() bool { // DoUpdate updates the resource with the plan entry computed during plan. // Returns remote state if available, otherwise nil. -func (a *Adapter) DoUpdate(ctx context.Context, id string, newState any, entry *PlanEntry) (any, error) { +func (a *Adapter) DoUpdate(ctx context.Context, engine *Engine, id string, newState any, entry *PlanEntry) (any, error) { if a.doUpdate == nil { return nil, errors.New("internal error: DoUpdate not found") } - outs, err := a.doUpdate.Call(ctx, id, newState, entry) + outs, err := a.doUpdate.Call(ctx, engine, id, newState, entry) if err != nil { return nil, err } @@ -521,40 +483,6 @@ func (a *Adapter) DoResize(ctx context.Context, id string, newState any, entry * return err } -// WaitAfterCreate waits for the resource to become ready after creation. -// If the resource doesn't implement this method, this is a no-op. -// Returns the updated remoteState if available, otherwise returns nil -func (a *Adapter) WaitAfterCreate(ctx context.Context, id string, newState any) (any, error) { - if a.waitAfterCreate == nil { - return nil, nil // no-op if not implemented - } - - outs, err := a.waitAfterCreate.Call(ctx, id, newState) - if err != nil { - return nil, err - } - - remoteState := normalizeNilPointer(outs[0]) - return remoteState, nil -} - -// WaitAfterUpdate waits for the resource to become ready after update. -// If the resource doesn't implement this method, this is a no-op. -// Returns the updated remoteState if available, otherwise returns nil. -func (a *Adapter) WaitAfterUpdate(ctx context.Context, id string, newState any) (any, error) { - if a.waitAfterUpdate == nil { - return nil, nil // no-op if not implemented - } - - outs, err := a.waitAfterUpdate.Call(ctx, id, newState) - if err != nil { - return nil, err - } - - remoteState := normalizeNilPointer(outs[0]) - return remoteState, nil -} - // WaitAfterDelete waits for the resource to be fully removed after DoDelete. // If the resource doesn't implement this method, this is a no-op. func (a *Adapter) WaitAfterDelete(ctx context.Context, id string) error { diff --git a/bundle/direct/dresources/alert.go b/bundle/direct/dresources/alert.go index a18641e810a..af71e378d41 100644 --- a/bundle/direct/dresources/alert.go +++ b/bundle/direct/dresources/alert.go @@ -37,7 +37,7 @@ func (r *ResourceAlert) DoRead(ctx context.Context, id string) (*sql.AlertV2, er } // DoCreate creates the alert and returns its id. -func (r *ResourceAlert) DoCreate(ctx context.Context, config *sql.AlertV2) (string, *sql.AlertV2, error) { +func (r *ResourceAlert) DoCreate(ctx context.Context, _ *Engine, config *sql.AlertV2) (string, *sql.AlertV2, error) { request := sql.CreateAlertV2Request{ Alert: *config, } @@ -49,7 +49,7 @@ func (r *ResourceAlert) DoCreate(ctx context.Context, config *sql.AlertV2) (stri } // DoUpdate updates the alert in place. -func (r *ResourceAlert) DoUpdate(ctx context.Context, id string, config *sql.AlertV2, _ *PlanEntry) (*sql.AlertV2, error) { +func (r *ResourceAlert) DoUpdate(ctx context.Context, _ *Engine, id string, config *sql.AlertV2, _ *PlanEntry) (*sql.AlertV2, error) { request := sql.UpdateAlertV2Request{ Id: id, Alert: *config, diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 54541d94ba9..219c7354e9c 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -974,7 +974,8 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W require.Error(t, err) // TODO: if errors.Is(err, databricks.ErrResourceDoesNotExist) {... } - createdID, remoteStateFromCreate, err := adapter.DoCreate(ctx, newState) + nopEngine := NewNopEngine(adapter.StateType()) + createdID, remoteStateFromCreate, err := adapter.DoCreate(ctx, nopEngine, newState) require.NoError(t, err, "DoCreate failed state=%v", newState) require.NotEmpty(t, createdID, "ID returned from DoCreate was empty") @@ -1000,14 +1001,8 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W "unexpected differences between remappedState and remappedRemoteStateFromCreate") } - remoteStateFromWaitCreate, err := adapter.WaitAfterCreate(ctx, createdID, newState) - require.NoError(t, err) - if remoteStateFromWaitCreate != nil { - require.Equal(t, remote, remoteStateFromWaitCreate) - } - if adapter.HasDoUpdate() { - remoteStateFromUpdate, err := adapter.DoUpdate(ctx, createdID, newState, &deployplan.PlanEntry{}) + remoteStateFromUpdate, err := adapter.DoUpdate(ctx, nopEngine, createdID, newState, &deployplan.PlanEntry{}) require.NoError(t, err, "DoUpdate failed") if remoteStateFromUpdate != nil { remappedStateFromUpdate, err := adapter.RemapState(remoteStateFromUpdate) @@ -1026,14 +1021,6 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W remappedState = remappedStateFromUpdate } - remoteStateFromWaitUpdate, err := adapter.WaitAfterUpdate(ctx, createdID, newState) - require.NoError(t, err) - if remoteStateFromWaitUpdate != nil { - remappedStateFromWaitUpdate, err := adapter.RemapState(remoteStateFromWaitUpdate) - require.NoError(t, err) - ignoreFilter.requireEqual(t, remappedState, remappedStateFromWaitUpdate, - "unexpected differences between remappedState and remappedStateFromWaitUpdate") - } } require.NoError(t, structwalk.Walk(newState, func(path *structpath.PathNode, val any, field *reflect.StructField) { diff --git a/bundle/direct/dresources/app.go b/bundle/direct/dresources/app.go index 29e1110513c..372dcab43f5 100644 --- a/bundle/direct/dresources/app.go +++ b/bundle/direct/dresources/app.go @@ -116,7 +116,7 @@ func (r *ResourceApp) DoRead(ctx context.Context, id string) (*AppRemote, error) return remote, nil } -func (r *ResourceApp) DoCreate(ctx context.Context, config *AppState) (string, *AppRemote, error) { +func (r *ResourceApp) DoCreate(ctx context.Context, engine *Engine, config *AppState) (string, *AppRemote, error) { // Start app compute only when lifecycle.started=true is explicit. // For nil (omitted) or false, use no_compute=true (do not start compute). noCompute := config.Lifecycle == nil || config.Lifecycle.Started == nil || !*config.Lifecycle.Started @@ -154,7 +154,22 @@ func (r *ResourceApp) DoCreate(ctx context.Context, config *AppState) (string, * return "", nil, err } - return app.Name, nil, nil + // Save state as soon as the app exists so it is not orphaned if the wait or + // lifecycle management is interrupted. + engine.SetID(app.Name) + if err := engine.SaveState(config); err != nil { + return "", nil, err + } + + remote, err := r.waitForApp(ctx, r.client, config.Name) + if err != nil { + return "", nil, err + } + alreadyStarted := remote.Lifecycle != nil && remote.Lifecycle.Started != nil && *remote.Lifecycle.Started + if err := r.manageLifecycle(ctx, config.Name, config, alreadyStarted); err != nil { + return "", nil, err + } + return app.Name, remote, nil } var UpdateMaskFields = []string{ @@ -172,7 +187,7 @@ var UpdateMaskFields = []string{ var updateMask = strings.Join(UpdateMaskFields, ",") -func (r *ResourceApp) DoUpdate(ctx context.Context, id string, config *AppState, entry *PlanEntry) (*AppRemote, error) { +func (r *ResourceApp) DoUpdate(ctx context.Context, _ *Engine, id string, config *AppState, entry *PlanEntry) (*AppRemote, error) { // Deploy-only fields (source_code_path, config, // git_source, lifecycle) are not part of apps.App and thus excluded from the request body. if hasAppChanges(entry) { @@ -323,18 +338,6 @@ func (*ResourceApp) IsGone(remote *AppRemote) bool { return remote.ComputeStatus != nil && remote.ComputeStatus.State == apps.ComputeStateDeleting } -func (r *ResourceApp) WaitAfterCreate(ctx context.Context, id string, config *AppState) (*AppRemote, error) { - remote, err := r.waitForApp(ctx, r.client, config.Name) - if err != nil { - return nil, err - } - alreadyStarted := remote.Lifecycle != nil && remote.Lifecycle.Started != nil && *remote.Lifecycle.Started - if err := r.manageLifecycle(ctx, config.Name, config, alreadyStarted); err != nil { - return nil, err - } - return remote, nil -} - // waitForApp waits for the app to reach the target state. The target state is either ACTIVE or STOPPED. // Apps with no_compute set to true will reach the STOPPED state, otherwise they will reach the ACTIVE state. // We can't use the default waiter from SDK because it only waits on ACTIVE state but we need also STOPPED state. diff --git a/bundle/direct/dresources/app_test.go b/bundle/direct/dresources/app_test.go index 444dfbd255e..70b23704733 100644 --- a/bundle/direct/dresources/app_test.go +++ b/bundle/direct/dresources/app_test.go @@ -42,10 +42,18 @@ func TestAppDoCreate_RetriesWhenAppIsDeleting(t *testing.T) { server.Handle("GET", "/api/2.0/apps/{name}", func(req testserver.Request) any { getCallCount++ + if getCallCount == 1 { + return apps.App{ + Name: req.Vars["name"], + ComputeStatus: &apps.ComputeStatus{ + State: apps.ComputeStateDeleting, + }, + } + } return apps.App{ Name: req.Vars["name"], ComputeStatus: &apps.ComputeStatus{ - State: apps.ComputeStateDeleting, + State: apps.ComputeStateActive, }, } }) @@ -60,12 +68,12 @@ func TestAppDoCreate_RetriesWhenAppIsDeleting(t *testing.T) { r := (&ResourceApp{}).New(client) ctx := t.Context() - name, _, err := r.DoCreate(ctx, &AppState{App: apps.App{Name: "test-app"}}) + name, _, err := r.DoCreate(ctx, NewNopEngine(reflect.TypeFor[*AppState]()), &AppState{App: apps.App{Name: "test-app"}}) require.NoError(t, err) assert.Equal(t, "test-app", name) assert.Equal(t, 2, createCallCount, "expected Create to be called twice (1 retry)") - assert.Equal(t, 1, getCallCount, "expected Get to be called once to check app state") + assert.Equal(t, 2, getCallCount, "expected Get to be called twice: once to check app state, once by waitForApp") } // TestAppDoCreate_RetriesWhenGetReturnsNotFound verifies that DoCreate retries @@ -97,11 +105,19 @@ func TestAppDoCreate_RetriesWhenGetReturnsNotFound(t *testing.T) { server.Handle("GET", "/api/2.0/apps/{name}", func(req testserver.Request) any { getCallCount++ - return testserver.Response{ - StatusCode: 404, - Body: map[string]string{ - "error_code": "RESOURCE_DOES_NOT_EXIST", - "message": "App not found.", + if getCallCount == 1 { + return testserver.Response{ + StatusCode: 404, + Body: map[string]string{ + "error_code": "RESOURCE_DOES_NOT_EXIST", + "message": "App not found.", + }, + } + } + return apps.App{ + Name: req.Vars["name"], + ComputeStatus: &apps.ComputeStatus{ + State: apps.ComputeStateActive, }, } }) @@ -116,12 +132,12 @@ func TestAppDoCreate_RetriesWhenGetReturnsNotFound(t *testing.T) { r := (&ResourceApp{}).New(client) ctx := t.Context() - name, _, err := r.DoCreate(ctx, &AppState{App: apps.App{Name: "test-app"}}) + name, _, err := r.DoCreate(ctx, NewNopEngine(reflect.TypeFor[*AppState]()), &AppState{App: apps.App{Name: "test-app"}}) require.NoError(t, err) assert.Equal(t, "test-app", name) assert.Equal(t, 2, createCallCount, "expected Create to be called twice") - assert.Equal(t, 1, getCallCount, "expected Get to be called once to check app state") + assert.Equal(t, 2, getCallCount, "expected Get to be called twice: once to check app state, once by waitForApp") } func TestAppDoUpdate_UpdateMaskHasAllFields(t *testing.T) { diff --git a/bundle/direct/dresources/catalog.go b/bundle/direct/dresources/catalog.go index 604100e92dd..1af5c2aa93c 100644 --- a/bundle/direct/dresources/catalog.go +++ b/bundle/direct/dresources/catalog.go @@ -41,7 +41,7 @@ func (r *ResourceCatalog) DoRead(ctx context.Context, id string) (*catalog.Catal return r.client.Catalogs.GetByName(ctx, id) } -func (r *ResourceCatalog) DoCreate(ctx context.Context, config *catalog.CreateCatalog) (string, *catalog.CatalogInfo, error) { +func (r *ResourceCatalog) DoCreate(ctx context.Context, _ *Engine, config *catalog.CreateCatalog) (string, *catalog.CatalogInfo, error) { response, err := r.client.Catalogs.Create(ctx, *config) if err != nil || response == nil { return "", nil, err @@ -50,7 +50,7 @@ func (r *ResourceCatalog) DoCreate(ctx context.Context, config *catalog.CreateCa } // DoUpdate updates the catalog in place and returns remote state. -func (r *ResourceCatalog) DoUpdate(ctx context.Context, id string, config *catalog.CreateCatalog, _ *PlanEntry) (*catalog.CatalogInfo, error) { +func (r *ResourceCatalog) DoUpdate(ctx context.Context, _ *Engine, id string, config *catalog.CreateCatalog, _ *PlanEntry) (*catalog.CatalogInfo, error) { updateRequest := catalog.UpdateCatalog{ Comment: config.Comment, CustomMaxRetentionHours: config.CustomMaxRetentionHours, diff --git a/bundle/direct/dresources/cluster.go b/bundle/direct/dresources/cluster.go index 0605d50b86c..2cc505418f5 100644 --- a/bundle/direct/dresources/cluster.go +++ b/bundle/direct/dresources/cluster.go @@ -165,12 +165,40 @@ func (r *ResourceCluster) DoRead(ctx context.Context, id string) (*ClusterRemote return remote, nil } -func (r *ResourceCluster) DoCreate(ctx context.Context, config *ClusterState) (string, *ClusterRemote, error) { +func (r *ResourceCluster) DoCreate(ctx context.Context, engine *Engine, config *ClusterState) (string, *ClusterRemote, error) { wait, err := r.client.Clusters.Create(ctx, makeCreateCluster(&config.ClusterSpec)) if err != nil { return "", nil, err } - return wait.ClusterId, nil, nil + id := wait.ClusterId + + // Save state immediately after the cluster is created so it is not orphaned + // if the subsequent wait or terminate is interrupted. + engine.SetID(id) + if err := engine.SaveState(config); err != nil { + return "", nil, err + } + + // Always wait for RUNNING first: clusters start in PENDING state and must be polled. + _, err = r.client.Clusters.WaitGetClusterRunning(ctx, id, clusterWaitTimeout, nil) + if err != nil { + return "", nil, err + } + + if config.Lifecycle != nil && config.Lifecycle.Started != nil && !*config.Lifecycle.Started { + // started=false: terminate the cluster after it reaches RUNNING. + // Note: Delete terminates the cluster; permanent removal is a separate API (permanent-delete). + deleteWaiter, err := r.client.Clusters.Delete(ctx, compute.DeleteCluster{ClusterId: id}) + if err != nil { + return "", nil, err + } + _, err = deleteWaiter.GetWithTimeout(clusterWaitTimeout) + if err != nil { + return "", nil, err + } + } + + return id, nil, nil } // hasClusterChanges reports whether the plan entry contains any Update changes @@ -179,7 +207,7 @@ func hasClusterChanges(entry *PlanEntry) bool { return entry.Changes.HasChangeExcept("lifecycle", "lifecycle.started") } -func (r *ResourceCluster) DoUpdate(ctx context.Context, id string, config *ClusterState, entry *PlanEntry) (*ClusterRemote, error) { +func (r *ResourceCluster) DoUpdate(ctx context.Context, _ *Engine, id string, config *ClusterState, entry *PlanEntry) (*ClusterRemote, error) { if hasClusterChanges(entry) { // Same retry as in TF provider logic // https://github.com/databricks/terraform-provider-databricks/blob/3eecd0f90cf99d7777e79a3d03c41f9b2aafb004/clusters/resource_cluster.go#L624 @@ -208,51 +236,21 @@ func (r *ResourceCluster) DoUpdate(ctx context.Context, id string, config *Clust desiredStarted := *config.Lifecycle.Started alreadyRunning := remoteClusterIsRunning(entry) if desiredStarted && !alreadyRunning { - // lifecycle.started=true: fire Start; WaitAfterUpdate polls for RUNNING. + // lifecycle.started=true: fire Start and wait for RUNNING. _, err := r.client.Clusters.Start(ctx, compute.StartCluster{ClusterId: id}) + if err != nil { + return nil, err + } + _, err = r.client.Clusters.WaitGetClusterRunning(ctx, id, clusterWaitTimeout, nil) return nil, err } else if !desiredStarted && alreadyRunning { - // lifecycle.started=false: fire Delete; WaitAfterUpdate polls for TERMINATED. + // lifecycle.started=false: fire Delete and wait for TERMINATED. // Note: Delete terminates the cluster; permanent removal is a separate API (permanent-delete). _, err := r.client.Clusters.Delete(ctx, compute.DeleteCluster{ClusterId: id}) - return nil, err - } - - return nil, nil -} - -// WaitAfterUpdate waits for the cluster to reach the desired lifecycle state after DoUpdate. -func (r *ResourceCluster) WaitAfterUpdate(ctx context.Context, id string, config *ClusterState) (*ClusterRemote, error) { - if config.Lifecycle == nil || config.Lifecycle.Started == nil { - return nil, nil - } - - if *config.Lifecycle.Started { - _, err := r.client.Clusters.WaitGetClusterRunning(ctx, id, clusterWaitTimeout, nil) - return nil, err - } - - _, err := r.client.Clusters.WaitGetClusterTerminated(ctx, id, clusterWaitTimeout, nil) - return nil, err -} - -// WaitAfterCreate waits for the cluster to reach RUNNING state (clusters always start on creation). -// When lifecycle.started=false, it then terminates the cluster. -func (r *ResourceCluster) WaitAfterCreate(ctx context.Context, id string, config *ClusterState) (*ClusterRemote, error) { - // Always wait for RUNNING first: clusters start in PENDING state and must be polled. - _, err := r.client.Clusters.WaitGetClusterRunning(ctx, id, clusterWaitTimeout, nil) - if err != nil { - return nil, err - } - - if config.Lifecycle != nil && config.Lifecycle.Started != nil && !*config.Lifecycle.Started { - // started=false: terminate the cluster after it reaches RUNNING. - // Note: Delete terminates the cluster; permanent removal is a separate API (permanent-delete). - deleteWaiter, err := r.client.Clusters.Delete(ctx, compute.DeleteCluster{ClusterId: id}) if err != nil { return nil, err } - _, err = deleteWaiter.GetWithTimeout(clusterWaitTimeout) + _, err = r.client.Clusters.WaitGetClusterTerminated(ctx, id, clusterWaitTimeout, nil) return nil, err } @@ -276,8 +274,9 @@ func (r *ResourceCluster) DoResize(ctx context.Context, id string, config *Clust } // Cluster is not running; fall back to the full clusters/edit path. + // DoUpdate ignores its Engine argument, so passing nil here is safe. log.Debugf(ctx, "cluster %s: resize returned INVALID_STATE (%s), falling back to edit", id, err) - _, err = r.DoUpdate(ctx, id, config, entry) + _, err = r.DoUpdate(ctx, nil, id, config, entry) return err } diff --git a/bundle/direct/dresources/dashboard.go b/bundle/direct/dresources/dashboard.go index aaf1feea616..6bf959b2c21 100644 --- a/bundle/direct/dresources/dashboard.go +++ b/bundle/direct/dresources/dashboard.go @@ -305,7 +305,7 @@ func responseToState(createOrUpdateResp *dashboards.Dashboard, publishResp *dash } } -func (r *ResourceDashboard) DoCreate(ctx context.Context, config *DashboardState) (string, *DashboardState, error) { +func (r *ResourceDashboard) DoCreate(ctx context.Context, _ *Engine, config *DashboardState) (string, *DashboardState, error) { dashboard, err := prepareDashboardRequest(config) if err != nil { return "", nil, err @@ -362,7 +362,7 @@ func (r *ResourceDashboard) DoCreate(ctx context.Context, config *DashboardState return createResp.DashboardId, responseToState(createResp, publishResp, dashboard.SerializedDashboard, config.Published), nil } -func (r *ResourceDashboard) DoUpdate(ctx context.Context, id string, config *DashboardState, _ *PlanEntry) (*DashboardState, error) { +func (r *ResourceDashboard) DoUpdate(ctx context.Context, _ *Engine, id string, config *DashboardState, _ *PlanEntry) (*DashboardState, error) { dashboard, err := prepareDashboardRequest(config) if err != nil { return nil, err diff --git a/bundle/direct/dresources/database_catalog.go b/bundle/direct/dresources/database_catalog.go index a1a7ebdef44..f3970c4cc9f 100644 --- a/bundle/direct/dresources/database_catalog.go +++ b/bundle/direct/dresources/database_catalog.go @@ -24,7 +24,7 @@ func (r *ResourceDatabaseCatalog) DoRead(ctx context.Context, id string) (*datab return r.client.Database.GetDatabaseCatalogByName(ctx, id) } -func (r *ResourceDatabaseCatalog) DoCreate(ctx context.Context, config *database.DatabaseCatalog) (string, *database.DatabaseCatalog, error) { +func (r *ResourceDatabaseCatalog) DoCreate(ctx context.Context, _ *Engine, config *database.DatabaseCatalog) (string, *database.DatabaseCatalog, error) { result, err := r.client.Database.CreateDatabaseCatalog(ctx, database.CreateDatabaseCatalogRequest{ Catalog: *config, }) diff --git a/bundle/direct/dresources/database_instance.go b/bundle/direct/dresources/database_instance.go index 2169a61fc8e..35251eb526c 100644 --- a/bundle/direct/dresources/database_instance.go +++ b/bundle/direct/dresources/database_instance.go @@ -25,38 +25,42 @@ func (d *ResourceDatabaseInstance) DoRead(ctx context.Context, id string) (*data return d.client.Database.GetDatabaseInstanceByName(ctx, id) } -func (d *ResourceDatabaseInstance) DoCreate(ctx context.Context, config *database.DatabaseInstance) (string, *database.DatabaseInstance, error) { +func (d *ResourceDatabaseInstance) DoCreate(ctx context.Context, engine *Engine, config *database.DatabaseInstance) (string, *database.DatabaseInstance, error) { waiter, err := d.client.Database.CreateDatabaseInstance(ctx, database.CreateDatabaseInstanceRequest{ DatabaseInstance: *config, }) if err != nil { return "", nil, err } - return waiter.Response.Name, nil, nil -} + id := waiter.Response.Name -func (d *ResourceDatabaseInstance) DoUpdate(ctx context.Context, id string, config *database.DatabaseInstance, _ *PlanEntry) (*database.DatabaseInstance, error) { - request := database.UpdateDatabaseInstanceRequest{ - DatabaseInstance: *config, - Name: config.Name, - UpdateMask: "*", + // Save state immediately after the instance is created so it is not orphaned + // if the subsequent wait is interrupted. + engine.SetID(id) + if err := engine.SaveState(config); err != nil { + return "", nil, err } - request.DatabaseInstance.Uid = id - _, err := d.client.Database.UpdateDatabaseInstance(ctx, request) - return nil, err -} -func (d *ResourceDatabaseInstance) WaitAfterCreate(ctx context.Context, id string, config *database.DatabaseInstance) (*database.DatabaseInstance, error) { - waiter := &database.WaitGetDatabaseInstanceDatabaseAvailable[database.DatabaseInstance]{ + waiterObj := &database.WaitGetDatabaseInstanceDatabaseAvailable[database.DatabaseInstance]{ Response: config, Name: config.Name, Poll: func(timeout time.Duration, callback func(*database.DatabaseInstance)) (*database.DatabaseInstance, error) { return d.client.Database.WaitGetDatabaseInstanceDatabaseAvailable(ctx, config.Name, timeout, callback) }, } - // _ is remoteState, should we return it here? - _, err := waiter.GetWithTimeout(20 * time.Minute) + _, err = waiterObj.GetWithTimeout(20 * time.Minute) + return id, nil, err +} + +func (d *ResourceDatabaseInstance) DoUpdate(ctx context.Context, _ *Engine, id string, config *database.DatabaseInstance, _ *PlanEntry) (*database.DatabaseInstance, error) { + request := database.UpdateDatabaseInstanceRequest{ + DatabaseInstance: *config, + Name: config.Name, + UpdateMask: "*", + } + request.DatabaseInstance.Uid = id + _, err := d.client.Database.UpdateDatabaseInstance(ctx, request) return nil, err } diff --git a/bundle/direct/dresources/engine.go b/bundle/direct/dresources/engine.go new file mode 100644 index 00000000000..c4c8be8e25f --- /dev/null +++ b/bundle/direct/dresources/engine.go @@ -0,0 +1,47 @@ +package dresources + +import ( + "errors" + "fmt" + "reflect" +) + +// Engine provides state persistence to resource implementations. +// Pass it to DoCreate or DoUpdate to save intermediate state before long-running +// wait operations, so the resource is not orphaned if deployment is interrupted. +type Engine struct { + id string + stateType reflect.Type + saveFunc func(id string, x any) error +} + +// NewEngine creates an Engine with the given state type and save function. +// The framework calls this before invoking DoCreate or DoUpdate. +func NewEngine(stateType reflect.Type, saveFunc func(id string, x any) error) *Engine { + return &Engine{id: "", stateType: stateType, saveFunc: saveFunc} +} + +// NewNopEngine creates an Engine that discards all saves. Use in tests. +func NewNopEngine(stateType reflect.Type) *Engine { + return NewEngine(stateType, func(_ string, _ any) error { return nil }) +} + +// SetID sets the resource id for subsequent SaveState calls. +// Must be called before SaveState during DoCreate; for DoUpdate the Engine is +// pre-configured with the existing id. +func (e *Engine) SetID(id string) { + e.id = id +} + +// SaveState saves the resource state. x must be of the same pointer-to-struct +// type as the resource's state type. Returns an error if SetID was not called. +func (e *Engine) SaveState(x any) error { + if e.id == "" { + return errors.New("SaveState: id not set, call SetID first") + } + xt := reflect.TypeOf(x) + if xt != e.stateType { + return fmt.Errorf("SaveState: type mismatch: expected %v, got %v", e.stateType, xt) + } + return e.saveFunc(e.id, x) +} diff --git a/bundle/direct/dresources/experiment.go b/bundle/direct/dresources/experiment.go index e4f2e8ebbd7..a77e97165c4 100644 --- a/bundle/direct/dresources/experiment.go +++ b/bundle/direct/dresources/experiment.go @@ -52,7 +52,7 @@ func (r *ResourceExperiment) DoRead(ctx context.Context, id string) (*ml.Experim return result.Experiment, nil } -func (r *ResourceExperiment) DoCreate(ctx context.Context, config *ml.CreateExperiment) (string, *ml.Experiment, error) { +func (r *ResourceExperiment) DoCreate(ctx context.Context, _ *Engine, config *ml.CreateExperiment) (string, *ml.Experiment, error) { result, err := r.client.Experiments.CreateExperiment(ctx, *config) if err != nil { return "", nil, err @@ -60,7 +60,7 @@ func (r *ResourceExperiment) DoCreate(ctx context.Context, config *ml.CreateExpe return result.ExperimentId, nil, nil } -func (r *ResourceExperiment) DoUpdate(ctx context.Context, id string, config *ml.CreateExperiment, _ *PlanEntry) (*ml.Experiment, error) { +func (r *ResourceExperiment) DoUpdate(ctx context.Context, _ *Engine, id string, config *ml.CreateExperiment, _ *PlanEntry) (*ml.Experiment, error) { updateReq := ml.UpdateExperiment{ ExperimentId: id, NewName: config.Name, diff --git a/bundle/direct/dresources/external_location.go b/bundle/direct/dresources/external_location.go index 64eace48eb3..be29bed5830 100644 --- a/bundle/direct/dresources/external_location.go +++ b/bundle/direct/dresources/external_location.go @@ -44,7 +44,7 @@ func (r *ResourceExternalLocation) DoRead(ctx context.Context, id string) (*cata return r.client.ExternalLocations.GetByName(ctx, id) } -func (r *ResourceExternalLocation) DoCreate(ctx context.Context, config *catalog.CreateExternalLocation) (string, *catalog.ExternalLocationInfo, error) { +func (r *ResourceExternalLocation) DoCreate(ctx context.Context, _ *Engine, config *catalog.CreateExternalLocation) (string, *catalog.ExternalLocationInfo, error) { response, err := r.client.ExternalLocations.Create(ctx, *config) if err != nil || response == nil { return "", nil, err @@ -53,7 +53,7 @@ func (r *ResourceExternalLocation) DoCreate(ctx context.Context, config *catalog } // DoUpdate updates the external location in place and returns remote state. -func (r *ResourceExternalLocation) DoUpdate(ctx context.Context, id string, config *catalog.CreateExternalLocation, _ *PlanEntry) (*catalog.ExternalLocationInfo, error) { +func (r *ResourceExternalLocation) DoUpdate(ctx context.Context, _ *Engine, id string, config *catalog.CreateExternalLocation, _ *PlanEntry) (*catalog.ExternalLocationInfo, error) { updateRequest := catalog.UpdateExternalLocation{ Comment: config.Comment, CredentialName: config.CredentialName, diff --git a/bundle/direct/dresources/grants.go b/bundle/direct/dresources/grants.go index f0a9423838c..83ef12c5cf0 100644 --- a/bundle/direct/dresources/grants.go +++ b/bundle/direct/dresources/grants.go @@ -106,8 +106,8 @@ func (r *ResourceGrants) DoRead(ctx context.Context, id string) (*GrantsState, e }, nil } -func (r *ResourceGrants) DoCreate(ctx context.Context, state *GrantsState) (string, *GrantsState, error) { - _, err := r.DoUpdate(ctx, "", state, nil) +func (r *ResourceGrants) DoCreate(ctx context.Context, engine *Engine, state *GrantsState) (string, *GrantsState, error) { + _, err := r.DoUpdate(ctx, engine, "", state, nil) if err != nil { // Grants Update is idempotent (additive PATCH), so retrying on transient errors is safe. return "", nil, retrySafe(err) @@ -116,7 +116,7 @@ func (r *ResourceGrants) DoCreate(ctx context.Context, state *GrantsState) (stri return state.SecurableType + "/" + state.FullName, nil, nil } -func (r *ResourceGrants) DoUpdate(ctx context.Context, _ string, state *GrantsState, entry *PlanEntry) (*GrantsState, error) { +func (r *ResourceGrants) DoUpdate(ctx context.Context, _ *Engine, _ string, state *GrantsState, entry *PlanEntry) (*GrantsState, error) { if state.FullName == "" { return nil, errors.New("internal error: grants full_name must be resolved before deployment") } diff --git a/bundle/direct/dresources/job.go b/bundle/direct/dresources/job.go index 60eab5cf93c..20696e238c7 100644 --- a/bundle/direct/dresources/job.go +++ b/bundle/direct/dresources/job.go @@ -149,7 +149,7 @@ func makeJobRemote(job *jobs.Job) *JobRemote { } } -func (r *ResourceJob) DoCreate(ctx context.Context, config *jobs.JobSettings) (string, *JobRemote, error) { +func (r *ResourceJob) DoCreate(ctx context.Context, _ *Engine, config *jobs.JobSettings) (string, *JobRemote, error) { request, err := makeCreateJob(*config) if err != nil { return "", nil, err @@ -161,7 +161,7 @@ func (r *ResourceJob) DoCreate(ctx context.Context, config *jobs.JobSettings) (s return strconv.FormatInt(response.JobId, 10), nil, nil } -func (r *ResourceJob) DoUpdate(ctx context.Context, id string, config *jobs.JobSettings, _ *PlanEntry) (*JobRemote, error) { +func (r *ResourceJob) DoUpdate(ctx context.Context, _ *Engine, id string, config *jobs.JobSettings, _ *PlanEntry) (*JobRemote, error) { request, err := makeResetJob(*config, id) if err != nil { return nil, err diff --git a/bundle/direct/dresources/model.go b/bundle/direct/dresources/model.go index ad8a9cca5a3..f207b960cc6 100644 --- a/bundle/direct/dresources/model.go +++ b/bundle/direct/dresources/model.go @@ -64,7 +64,7 @@ func (r *ResourceMlflowModel) DoRead(ctx context.Context, id string) (*MlflowMod }, nil } -func (r *ResourceMlflowModel) DoCreate(ctx context.Context, config *ml.CreateModelRequest) (string, *MlflowModelRemote, error) { +func (r *ResourceMlflowModel) DoCreate(ctx context.Context, _ *Engine, config *ml.CreateModelRequest) (string, *MlflowModelRemote, error) { response, err := r.client.ModelRegistry.CreateModel(ctx, *config) if err != nil { return "", nil, err @@ -77,7 +77,7 @@ func (r *ResourceMlflowModel) DoCreate(ctx context.Context, config *ml.CreateMod return response.RegisteredModel.Name, nil, nil } -func (r *ResourceMlflowModel) DoUpdate(ctx context.Context, id string, config *ml.CreateModelRequest, entry *PlanEntry) (*MlflowModelRemote, error) { +func (r *ResourceMlflowModel) DoUpdate(ctx context.Context, _ *Engine, id string, config *ml.CreateModelRequest, entry *PlanEntry) (*MlflowModelRemote, error) { updateRequest := ml.UpdateModelRequest{ Name: id, Description: config.Description, diff --git a/bundle/direct/dresources/model_serving_endpoint.go b/bundle/direct/dresources/model_serving_endpoint.go index e8a1917c2f2..0ccdc734b65 100644 --- a/bundle/direct/dresources/model_serving_endpoint.go +++ b/bundle/direct/dresources/model_serving_endpoint.go @@ -151,13 +151,22 @@ func (r *ResourceModelServingEndpoint) DoRead(ctx context.Context, id string) (* return newModelServingEndpointRemote(endpoint), nil } -func (r *ResourceModelServingEndpoint) DoCreate(ctx context.Context, config *serving.CreateServingEndpoint) (string, *ModelServingEndpointRemote, error) { +func (r *ResourceModelServingEndpoint) DoCreate(ctx context.Context, engine *Engine, config *serving.CreateServingEndpoint) (string, *ModelServingEndpointRemote, error) { waiter, err := r.client.ServingEndpoints.Create(ctx, *config) if err != nil { return "", nil, err } + id := waiter.Response.Name - return waiter.Response.Name, nil, nil + // Save state immediately after the endpoint is created so it is not orphaned + // if the subsequent wait is interrupted. + engine.SetID(id) + if err := engine.SaveState(config); err != nil { + return "", nil, err + } + + remote, err := r.waitForEndpointReady(ctx, config.Name) + return id, remote, err } // waitForEndpointReady waits for the serving endpoint to be ready (not updating) @@ -169,14 +178,6 @@ func (r *ResourceModelServingEndpoint) waitForEndpointReady(ctx context.Context, return newModelServingEndpointRemote(details), nil } -func (r *ResourceModelServingEndpoint) WaitAfterCreate(ctx context.Context, id string, config *serving.CreateServingEndpoint) (*ModelServingEndpointRemote, error) { - return r.waitForEndpointReady(ctx, config.Name) -} - -func (r *ResourceModelServingEndpoint) WaitAfterUpdate(ctx context.Context, id string, config *serving.CreateServingEndpoint) (*ModelServingEndpointRemote, error) { - return r.waitForEndpointReady(ctx, config.Name) -} - func (r *ResourceModelServingEndpoint) updateAiGateway(ctx context.Context, id string, aiGateway *serving.AiGatewayConfig) error { if aiGateway == nil { req := serving.PutAiGatewayRequest{ @@ -309,7 +310,7 @@ func (r *ResourceModelServingEndpoint) updateTags(ctx context.Context, id string return nil } -func (r *ResourceModelServingEndpoint) DoUpdate(ctx context.Context, id string, config *serving.CreateServingEndpoint, entry *PlanEntry) (*ModelServingEndpointRemote, error) { +func (r *ResourceModelServingEndpoint) DoUpdate(ctx context.Context, _ *Engine, id string, config *serving.CreateServingEndpoint, entry *PlanEntry) (*ModelServingEndpointRemote, error) { var err error // Terraform makes these API calls sequentially. We do the same here. @@ -343,7 +344,7 @@ func (r *ResourceModelServingEndpoint) DoUpdate(ctx context.Context, id string, } } - return nil, nil + return r.waitForEndpointReady(ctx, config.Name) } func (r *ResourceModelServingEndpoint) DoDelete(ctx context.Context, id string, _ *serving.CreateServingEndpoint) error { diff --git a/bundle/direct/dresources/permissions.go b/bundle/direct/dresources/permissions.go index e99311757a2..26387a4530b 100644 --- a/bundle/direct/dresources/permissions.go +++ b/bundle/direct/dresources/permissions.go @@ -217,9 +217,9 @@ func (r *ResourcePermissions) DoRead(ctx context.Context, id string) (*Permissio } // DoCreate calls https://docs.databricks.com/api/workspace/jobs/setjobpermissions. -func (r *ResourcePermissions) DoCreate(ctx context.Context, newState *PermissionsState) (string, *PermissionsState, error) { +func (r *ResourcePermissions) DoCreate(ctx context.Context, engine *Engine, newState *PermissionsState) (string, *PermissionsState, error) { // should we remember the default here? - _, err := r.DoUpdate(ctx, newState.ObjectID, newState, nil) + _, err := r.DoUpdate(ctx, engine, newState.ObjectID, newState, nil) if err != nil { // Permissions Set is idempotent (PUT), so retrying on transient errors is safe. return "", nil, retrySafe(err) @@ -229,7 +229,7 @@ func (r *ResourcePermissions) DoCreate(ctx context.Context, newState *Permission } // DoUpdate calls https://docs.databricks.com/api/workspace/jobs/setjobpermissions. -func (r *ResourcePermissions) DoUpdate(ctx context.Context, _ string, newState *PermissionsState, _ *PlanEntry) (*PermissionsState, error) { +func (r *ResourcePermissions) DoUpdate(ctx context.Context, _ *Engine, _ string, newState *PermissionsState, _ *PlanEntry) (*PermissionsState, error) { extractedType, extractedID, err := parsePermissionsID(newState.ObjectID) if err != nil { return nil, err diff --git a/bundle/direct/dresources/pipeline.go b/bundle/direct/dresources/pipeline.go index 20d2eb08245..3d77056d6aa 100644 --- a/bundle/direct/dresources/pipeline.go +++ b/bundle/direct/dresources/pipeline.go @@ -124,7 +124,7 @@ func makePipelineRemote(p *pipelines.GetPipelineResponse) *PipelineRemote { } } -func (r *ResourcePipeline) DoCreate(ctx context.Context, config *pipelines.CreatePipeline) (string, *PipelineRemote, error) { +func (r *ResourcePipeline) DoCreate(ctx context.Context, _ *Engine, config *pipelines.CreatePipeline) (string, *PipelineRemote, error) { response, err := r.client.Pipelines.Create(ctx, *config) if err != nil { return "", nil, err @@ -132,7 +132,7 @@ func (r *ResourcePipeline) DoCreate(ctx context.Context, config *pipelines.Creat return response.PipelineId, nil, nil } -func (r *ResourcePipeline) DoUpdate(ctx context.Context, id string, config *pipelines.CreatePipeline, _ *PlanEntry) (*PipelineRemote, error) { +func (r *ResourcePipeline) DoUpdate(ctx context.Context, _ *Engine, id string, config *pipelines.CreatePipeline, _ *PlanEntry) (*PipelineRemote, error) { request := pipelines.EditPipeline{ AllowDuplicateNames: config.AllowDuplicateNames, BudgetPolicyId: config.BudgetPolicyId, diff --git a/bundle/direct/dresources/postgres_branch.go b/bundle/direct/dresources/postgres_branch.go index b033b42a8f0..88cfb884aa3 100644 --- a/bundle/direct/dresources/postgres_branch.go +++ b/bundle/direct/dresources/postgres_branch.go @@ -106,7 +106,7 @@ func (r *ResourcePostgresBranch) DoRead(ctx context.Context, id string) (*Postgr return makePostgresBranchRemote(branch), nil } -func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, config *PostgresBranchState) (string, *PostgresBranchRemote, error) { +func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, _ *Engine, config *PostgresBranchState) (string, *PostgresBranchRemote, error) { waiter, err := r.client.Postgres.CreateBranch(ctx, postgres.CreateBranchRequest{ BranchId: config.BranchId, Parent: config.Parent, @@ -140,7 +140,7 @@ func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, config *PostgresB return remote.Name, remote, nil } -func (r *ResourcePostgresBranch) DoUpdate(ctx context.Context, id string, config *PostgresBranchState, entry *PlanEntry) (*PostgresBranchRemote, error) { +func (r *ResourcePostgresBranch) DoUpdate(ctx context.Context, _ *Engine, id string, config *PostgresBranchState, entry *PlanEntry) (*PostgresBranchRemote, error) { // Build the mask from the plan's change list and prefix with "spec." (the // API expects paths relative to Branch). The API rejects mask entries // that aren't also populated in the request body, and a wildcard "*" diff --git a/bundle/direct/dresources/postgres_catalog.go b/bundle/direct/dresources/postgres_catalog.go index 2a35b9e7064..279f38d2fe2 100644 --- a/bundle/direct/dresources/postgres_catalog.go +++ b/bundle/direct/dresources/postgres_catalog.go @@ -91,7 +91,7 @@ func (r *ResourcePostgresCatalog) DoRead(ctx context.Context, id string) (*Postg return makePostgresCatalogRemote(catalog), nil } -func (r *ResourcePostgresCatalog) DoCreate(ctx context.Context, config *PostgresCatalogState) (string, *PostgresCatalogRemote, error) { +func (r *ResourcePostgresCatalog) DoCreate(ctx context.Context, _ *Engine, config *PostgresCatalogState) (string, *PostgresCatalogRemote, error) { waiter, err := r.client.Postgres.CreateCatalog(ctx, postgres.CreateCatalogRequest{ CatalogId: config.CatalogId, Catalog: postgres.Catalog{ diff --git a/bundle/direct/dresources/postgres_endpoint.go b/bundle/direct/dresources/postgres_endpoint.go index c370ff120be..18bc8c5a6d9 100644 --- a/bundle/direct/dresources/postgres_endpoint.go +++ b/bundle/direct/dresources/postgres_endpoint.go @@ -137,7 +137,7 @@ func (r *ResourcePostgresEndpoint) waitForReconciliation(ctx context.Context, na } } -func (r *ResourcePostgresEndpoint) DoCreate(ctx context.Context, config *PostgresEndpointState) (string, *PostgresEndpointRemote, error) { +func (r *ResourcePostgresEndpoint) DoCreate(ctx context.Context, _ *Engine, config *PostgresEndpointState) (string, *PostgresEndpointRemote, error) { waiter, err := r.client.Postgres.CreateEndpoint(ctx, postgres.CreateEndpointRequest{ EndpointId: config.EndpointId, Parent: config.Parent, @@ -176,7 +176,7 @@ func (r *ResourcePostgresEndpoint) DoCreate(ctx context.Context, config *Postgre return remote.Name, remote, nil } -func (r *ResourcePostgresEndpoint) DoUpdate(ctx context.Context, id string, config *PostgresEndpointState, entry *PlanEntry) (*PostgresEndpointRemote, error) { +func (r *ResourcePostgresEndpoint) DoUpdate(ctx context.Context, _ *Engine, id string, config *PostgresEndpointState, entry *PlanEntry) (*PostgresEndpointRemote, error) { // Build update mask from fields that have action="update" in the changes map. // This excludes immutable fields and fields that haven't changed. // Prefix with "spec." because the API expects paths relative to the Endpoint object, diff --git a/bundle/direct/dresources/postgres_project.go b/bundle/direct/dresources/postgres_project.go index ae9fa51ccb1..42410f9705e 100644 --- a/bundle/direct/dresources/postgres_project.go +++ b/bundle/direct/dresources/postgres_project.go @@ -102,7 +102,7 @@ func (r *ResourcePostgresProject) DoRead(ctx context.Context, id string) (*Postg return makePostgresProjectRemote(project), nil } -func (r *ResourcePostgresProject) DoCreate(ctx context.Context, config *PostgresProjectState) (string, *PostgresProjectRemote, error) { +func (r *ResourcePostgresProject) DoCreate(ctx context.Context, _ *Engine, config *PostgresProjectState) (string, *PostgresProjectRemote, error) { waiter, err := r.client.Postgres.CreateProject(ctx, postgres.CreateProjectRequest{ ProjectId: config.ProjectId, Project: postgres.Project{ @@ -136,7 +136,7 @@ func (r *ResourcePostgresProject) DoCreate(ctx context.Context, config *Postgres return remote.Name, remote, nil } -func (r *ResourcePostgresProject) DoUpdate(ctx context.Context, id string, config *PostgresProjectState, entry *PlanEntry) (*PostgresProjectRemote, error) { +func (r *ResourcePostgresProject) DoUpdate(ctx context.Context, _ *Engine, id string, config *PostgresProjectState, entry *PlanEntry) (*PostgresProjectRemote, error) { // Build the mask from the plan's change list and prefix with "spec." (the // API expects paths relative to Project). The API rejects mask entries // that aren't also populated in the request body, and a wildcard "*" diff --git a/bundle/direct/dresources/postgres_synced_table.go b/bundle/direct/dresources/postgres_synced_table.go index 0f07c33293e..28013b07224 100644 --- a/bundle/direct/dresources/postgres_synced_table.go +++ b/bundle/direct/dresources/postgres_synced_table.go @@ -91,7 +91,7 @@ func (r *ResourcePostgresSyncedTable) DoRead(ctx context.Context, id string) (*P return makePostgresSyncedTableRemote(syncedTable), nil } -func (r *ResourcePostgresSyncedTable) DoCreate(ctx context.Context, config *PostgresSyncedTableState) (string, *PostgresSyncedTableRemote, error) { +func (r *ResourcePostgresSyncedTable) DoCreate(ctx context.Context, _ *Engine, config *PostgresSyncedTableState) (string, *PostgresSyncedTableRemote, error) { waiter, err := r.client.Postgres.CreateSyncedTable(ctx, postgres.CreateSyncedTableRequest{ SyncedTableId: config.SyncedTableId, SyncedTable: postgres.SyncedTable{ diff --git a/bundle/direct/dresources/quality_monitor.go b/bundle/direct/dresources/quality_monitor.go index c66fed4e0bb..7a0473ab543 100644 --- a/bundle/direct/dresources/quality_monitor.go +++ b/bundle/direct/dresources/quality_monitor.go @@ -72,7 +72,7 @@ func (r *ResourceQualityMonitor) DoRead(ctx context.Context, id string) (*catalo }) } -func (r *ResourceQualityMonitor) DoCreate(ctx context.Context, config *QualityMonitorState) (string, *catalog.MonitorInfo, error) { +func (r *ResourceQualityMonitor) DoCreate(ctx context.Context, _ *Engine, config *QualityMonitorState) (string, *catalog.MonitorInfo, error) { req := config.CreateMonitor req.TableName = config.TableName //nolint:staticcheck // Direct quality_monitor resource still uses legacy monitor endpoints; v1 data-quality migration is separate work. @@ -83,7 +83,7 @@ func (r *ResourceQualityMonitor) DoCreate(ctx context.Context, config *QualityMo return response.TableName, response, nil } -func (r *ResourceQualityMonitor) DoUpdate(ctx context.Context, id string, config *QualityMonitorState, _ *PlanEntry) (*catalog.MonitorInfo, error) { +func (r *ResourceQualityMonitor) DoUpdate(ctx context.Context, _ *Engine, id string, config *QualityMonitorState, _ *PlanEntry) (*catalog.MonitorInfo, error) { updateRequest := catalog.UpdateMonitor{ TableName: id, BaselineTableName: config.BaselineTableName, diff --git a/bundle/direct/dresources/registered_model.go b/bundle/direct/dresources/registered_model.go index b72038a79f0..b02d901556d 100644 --- a/bundle/direct/dresources/registered_model.go +++ b/bundle/direct/dresources/registered_model.go @@ -56,7 +56,7 @@ func (r *ResourceRegisteredModel) DoRead(ctx context.Context, id string) (*catal }) } -func (r *ResourceRegisteredModel) DoCreate(ctx context.Context, config *catalog.CreateRegisteredModelRequest) (string, *catalog.RegisteredModelInfo, error) { +func (r *ResourceRegisteredModel) DoCreate(ctx context.Context, _ *Engine, config *catalog.CreateRegisteredModelRequest) (string, *catalog.RegisteredModelInfo, error) { response, err := r.client.RegisteredModels.Create(ctx, *config) if err != nil { return "", nil, err @@ -65,7 +65,7 @@ func (r *ResourceRegisteredModel) DoCreate(ctx context.Context, config *catalog. return response.FullName, response, nil } -func (r *ResourceRegisteredModel) DoUpdate(ctx context.Context, id string, config *catalog.CreateRegisteredModelRequest, _ *PlanEntry) (*catalog.RegisteredModelInfo, error) { +func (r *ResourceRegisteredModel) DoUpdate(ctx context.Context, _ *Engine, id string, config *catalog.CreateRegisteredModelRequest, _ *PlanEntry) (*catalog.RegisteredModelInfo, error) { updateRequest := catalog.UpdateRegisteredModelRequest{ FullName: id, Comment: config.Comment, diff --git a/bundle/direct/dresources/schema.go b/bundle/direct/dresources/schema.go index 24c82ceb6a6..0e5716b4882 100644 --- a/bundle/direct/dresources/schema.go +++ b/bundle/direct/dresources/schema.go @@ -38,7 +38,7 @@ func (r *ResourceSchema) DoRead(ctx context.Context, id string) (*catalog.Schema return r.client.Schemas.GetByFullName(ctx, id) } -func (r *ResourceSchema) DoCreate(ctx context.Context, config *catalog.CreateSchema) (string, *catalog.SchemaInfo, error) { +func (r *ResourceSchema) DoCreate(ctx context.Context, _ *Engine, config *catalog.CreateSchema) (string, *catalog.SchemaInfo, error) { response, err := r.client.Schemas.Create(ctx, *config) if err != nil || response == nil { return "", nil, err @@ -47,7 +47,7 @@ func (r *ResourceSchema) DoCreate(ctx context.Context, config *catalog.CreateSch } // DoUpdate updates the schema in place and returns remote state. -func (r *ResourceSchema) DoUpdate(ctx context.Context, id string, config *catalog.CreateSchema, _ *PlanEntry) (*catalog.SchemaInfo, error) { +func (r *ResourceSchema) DoUpdate(ctx context.Context, _ *Engine, id string, config *catalog.CreateSchema, _ *PlanEntry) (*catalog.SchemaInfo, error) { updateRequest := catalog.UpdateSchema{ Comment: config.Comment, CustomMaxRetentionHours: config.CustomMaxRetentionHours, diff --git a/bundle/direct/dresources/schema_test.go b/bundle/direct/dresources/schema_test.go index d013610e052..e3a66bf4cc6 100644 --- a/bundle/direct/dresources/schema_test.go +++ b/bundle/direct/dresources/schema_test.go @@ -2,6 +2,7 @@ package dresources import ( "encoding/json" + "reflect" "testing" "github.com/databricks/databricks-sdk-go/service/catalog" @@ -24,7 +25,8 @@ func TestResourceSchema_DoUpdate_WithUnsupportedForceSendFields(t *testing.T) { ForceSendFields: nil, } - id, _, err := adapter.DoCreate(ctx, config) + nopEngine := NewNopEngine(reflect.TypeOf(config)) + id, _, err := adapter.DoCreate(ctx, nopEngine, config) require.NoError(t, err) config.Comment = "updated comment" @@ -37,7 +39,7 @@ func TestResourceSchema_DoUpdate_WithUnsupportedForceSendFields(t *testing.T) { "Owner", // Unsupported - should be filtered out } - _, err = adapter.DoUpdate(ctx, id, config, &PlanEntry{}) + _, err = adapter.DoUpdate(ctx, nopEngine, id, config, &PlanEntry{}) require.NoError(t, err) result, err := adapter.DoRead(ctx, id) diff --git a/bundle/direct/dresources/secret_scope.go b/bundle/direct/dresources/secret_scope.go index c811dc84d77..a77fb1ebd3c 100644 --- a/bundle/direct/dresources/secret_scope.go +++ b/bundle/direct/dresources/secret_scope.go @@ -66,7 +66,7 @@ func (r *ResourceSecretScope) DoRead(ctx context.Context, id string) (*workspace return nil, fmt.Errorf("secret scope %q not found", id) } -func (r *ResourceSecretScope) DoCreate(ctx context.Context, state *SecretScopeConfig) (string, *workspace.SecretScope, error) { +func (r *ResourceSecretScope) DoCreate(ctx context.Context, _ *Engine, state *SecretScopeConfig) (string, *workspace.SecretScope, error) { err := r.client.Secrets.CreateScope(ctx, state.CreateScope) if err != nil { return "", nil, err diff --git a/bundle/direct/dresources/secret_scope_acls.go b/bundle/direct/dresources/secret_scope_acls.go index ef04cb7cb6a..14cd05b8f91 100644 --- a/bundle/direct/dresources/secret_scope_acls.go +++ b/bundle/direct/dresources/secret_scope_acls.go @@ -92,7 +92,7 @@ func (r *ResourceSecretScopeAcls) RemapState(remote *SecretScopeAclsState) *Secr return remote } -func (r *ResourceSecretScopeAcls) DoCreate(ctx context.Context, state *SecretScopeAclsState) (string, *SecretScopeAclsState, error) { +func (r *ResourceSecretScopeAcls) DoCreate(ctx context.Context, _ *Engine, state *SecretScopeAclsState) (string, *SecretScopeAclsState, error) { err := r.setACLs(ctx, state.ScopeName, state.Acls) if err != nil { return "", nil, err @@ -109,7 +109,7 @@ func (r *ResourceSecretScopeAcls) DoUpdateWithID(ctx context.Context, id string, return state.ScopeName, nil, nil } -func (r *ResourceSecretScopeAcls) DoUpdate(ctx context.Context, id string, state *SecretScopeAclsState, _ *PlanEntry) (*SecretScopeAclsState, error) { +func (r *ResourceSecretScopeAcls) DoUpdate(ctx context.Context, _ *Engine, id string, state *SecretScopeAclsState, _ *PlanEntry) (*SecretScopeAclsState, error) { _, _, err := r.DoUpdateWithID(ctx, id, state) return nil, err } diff --git a/bundle/direct/dresources/sql_warehouse.go b/bundle/direct/dresources/sql_warehouse.go index 4854e5ed1fb..ecc4d66953d 100644 --- a/bundle/direct/dresources/sql_warehouse.go +++ b/bundle/direct/dresources/sql_warehouse.go @@ -118,7 +118,7 @@ func (r *ResourceSqlWarehouse) DoRead(ctx context.Context, id string) (*SqlWareh } // DoCreate creates the warehouse and returns its id. -func (r *ResourceSqlWarehouse) DoCreate(ctx context.Context, config *SqlWarehouseState) (string, *SqlWarehouseRemote, error) { +func (r *ResourceSqlWarehouse) DoCreate(ctx context.Context, _ *Engine, config *SqlWarehouseState) (string, *SqlWarehouseRemote, error) { waiter, err := r.client.Warehouses.Create(ctx, config.CreateWarehouseRequest) if err != nil { return "", nil, err @@ -133,7 +133,7 @@ func hasWarehouseChanges(entry *PlanEntry) bool { } // DoUpdate updates the warehouse in place. -func (r *ResourceSqlWarehouse) DoUpdate(ctx context.Context, id string, config *SqlWarehouseState, entry *PlanEntry) (*SqlWarehouseRemote, error) { +func (r *ResourceSqlWarehouse) DoUpdate(ctx context.Context, _ *Engine, id string, config *SqlWarehouseState, entry *PlanEntry) (*SqlWarehouseRemote, error) { edited := hasWarehouseChanges(entry) if edited { request := sql.EditWarehouseRequest{ diff --git a/bundle/direct/dresources/synced_database_table.go b/bundle/direct/dresources/synced_database_table.go index e1c0df12f00..05d40f1bb41 100644 --- a/bundle/direct/dresources/synced_database_table.go +++ b/bundle/direct/dresources/synced_database_table.go @@ -24,7 +24,7 @@ func (r *ResourceSyncedDatabaseTable) DoRead(ctx context.Context, name string) ( return r.client.Database.GetSyncedDatabaseTableByName(ctx, name) } -func (r *ResourceSyncedDatabaseTable) DoCreate(ctx context.Context, config *database.SyncedDatabaseTable) (string, *database.SyncedDatabaseTable, error) { +func (r *ResourceSyncedDatabaseTable) DoCreate(ctx context.Context, _ *Engine, config *database.SyncedDatabaseTable) (string, *database.SyncedDatabaseTable, error) { result, err := r.client.Database.CreateSyncedDatabaseTable(ctx, database.CreateSyncedDatabaseTableRequest{ SyncedTable: *config, }) diff --git a/bundle/direct/dresources/vector_search_endpoint.go b/bundle/direct/dresources/vector_search_endpoint.go index 12470872b62..fb599a1ba8e 100644 --- a/bundle/direct/dresources/vector_search_endpoint.go +++ b/bundle/direct/dresources/vector_search_endpoint.go @@ -80,24 +80,28 @@ func (r *ResourceVectorSearchEndpoint) DoRead(ctx context.Context, id string) (* return newVectorSearchEndpointRemote(info), nil } -func (r *ResourceVectorSearchEndpoint) DoCreate(ctx context.Context, config *vectorsearch.CreateEndpoint) (string, *VectorSearchEndpointRemote, error) { - waiter, err := r.client.VectorSearchEndpoints.CreateEndpoint(ctx, *config) +func (r *ResourceVectorSearchEndpoint) DoCreate(ctx context.Context, engine *Engine, config *vectorsearch.CreateEndpoint) (string, *VectorSearchEndpointRemote, error) { + _, err := r.client.VectorSearchEndpoints.CreateEndpoint(ctx, *config) if err != nil { return "", nil, err } id := config.Name - return id, newVectorSearchEndpointRemote(waiter.Response), nil -} -func (r *ResourceVectorSearchEndpoint) WaitAfterCreate(ctx context.Context, id string, config *vectorsearch.CreateEndpoint) (*VectorSearchEndpointRemote, error) { + // Save state immediately after the endpoint is created so it is not orphaned + // if the subsequent wait is interrupted. + engine.SetID(id) + if err := engine.SaveState(config); err != nil { + return "", nil, err + } + info, err := r.client.VectorSearchEndpoints.WaitGetEndpointVectorSearchEndpointOnline(ctx, config.Name, 60*time.Minute, nil) if err != nil { - return nil, err + return "", nil, err } - return newVectorSearchEndpointRemote(info), nil + return id, newVectorSearchEndpointRemote(info), nil } -func (r *ResourceVectorSearchEndpoint) DoUpdate(ctx context.Context, id string, config *vectorsearch.CreateEndpoint, entry *PlanEntry) (*VectorSearchEndpointRemote, error) { +func (r *ResourceVectorSearchEndpoint) DoUpdate(ctx context.Context, _ *Engine, id string, config *vectorsearch.CreateEndpoint, entry *PlanEntry) (*VectorSearchEndpointRemote, error) { if entry.Changes.HasChange(pathBudgetPolicyId) { _, err := r.client.VectorSearchEndpoints.UpdateEndpointBudgetPolicy(ctx, vectorsearch.PatchEndpointBudgetPolicyRequest{ EndpointName: id, diff --git a/bundle/direct/dresources/vector_search_index.go b/bundle/direct/dresources/vector_search_index.go index f7bd2f60f9f..389cd26e21f 100644 --- a/bundle/direct/dresources/vector_search_index.go +++ b/bundle/direct/dresources/vector_search_index.go @@ -128,8 +128,8 @@ func (r *ResourceVectorSearchIndex) DoRead(ctx context.Context, id string) (*Vec }, nil } -func (r *ResourceVectorSearchIndex) DoCreate(ctx context.Context, config *VectorSearchIndexState) (string, *VectorSearchIndexRemote, error) { - index, err := r.createIndex(ctx, config.CreateVectorIndexRequest) +func (r *ResourceVectorSearchIndex) DoCreate(ctx context.Context, engine *Engine, config *VectorSearchIndexState) (string, *VectorSearchIndexRemote, error) { + _, err := r.createIndex(ctx, config.CreateVectorIndexRequest) if err != nil { return "", nil, err } @@ -142,6 +142,33 @@ func (r *ResourceVectorSearchIndex) DoCreate(ctx context.Context, config *Vector return "", nil, err } config.EndpointUuid = endpointUuid + + // Save state immediately after the index is created (endpoint UUID now set) so it + // is not orphaned if the subsequent provisioning wait is interrupted. + engine.SetID(config.Name) + if err := engine.SaveState(config); err != nil { + return "", nil, err + } + + // CreateIndex returns immediately; poll until the embedding pipeline is ready so + // dependent resources and the next plan see a usable index. + index, err := retries.Poll(ctx, createIndexTimeout, func() (*vectorsearch.VectorIndex, *retries.Err) { + idx, getErr := r.client.VectorSearchIndexes.GetIndexByIndexName(ctx, config.Name) + if getErr != nil { + return nil, retries.Halt(getErr) + } + if idx.Status == nil || !idx.Status.Ready { + msg := "index is still provisioning" + if idx.Status != nil && idx.Status.Message != "" { + msg = idx.Status.Message + } + return nil, retries.Continues(msg) + } + return idx, nil + }) + if err != nil { + return "", nil, err + } return config.Name, &VectorSearchIndexRemote{VectorIndex: *index, EndpointUuid: endpointUuid}, nil } @@ -191,31 +218,6 @@ func (r *ResourceVectorSearchIndex) DoDelete(ctx context.Context, id string, _ * return r.client.VectorSearchIndexes.DeleteIndexByIndexName(ctx, id) } -// WaitAfterCreate polls GetIndex until Status.Ready=true. CreateIndex returns -// immediately with metadata of an index whose embedding pipeline is still -// provisioning; queries against an index that isn't ready fail. Blocking here -// lets dependent resources (and the next plan) see a usable index. -func (r *ResourceVectorSearchIndex) WaitAfterCreate(ctx context.Context, id string, config *VectorSearchIndexState) (*VectorSearchIndexRemote, error) { - index, err := retries.Poll(ctx, createIndexTimeout, func() (*vectorsearch.VectorIndex, *retries.Err) { - idx, getErr := r.client.VectorSearchIndexes.GetIndexByIndexName(ctx, id) - if getErr != nil { - return nil, retries.Halt(getErr) - } - if idx.Status == nil || !idx.Status.Ready { - msg := "index is still provisioning" - if idx.Status != nil && idx.Status.Message != "" { - msg = idx.Status.Message - } - return nil, retries.Continues(msg) - } - return idx, nil - }) - if err != nil { - return nil, err - } - return &VectorSearchIndexRemote{VectorIndex: *index, EndpointUuid: config.EndpointUuid}, nil -} - // WaitAfterDelete polls GetIndex until it returns 404. The DELETE call is // asynchronous, so without this a `bundle destroy` would report success while // the index is still being torn down. The framework calls this after dropping diff --git a/bundle/direct/dresources/volume.go b/bundle/direct/dresources/volume.go index 6c96e66eccb..3bb057f6958 100644 --- a/bundle/direct/dresources/volume.go +++ b/bundle/direct/dresources/volume.go @@ -40,7 +40,7 @@ func (r *ResourceVolume) DoRead(ctx context.Context, id string) (*catalog.Volume return r.client.Volumes.ReadByName(ctx, id) } -func (r *ResourceVolume) DoCreate(ctx context.Context, config *catalog.CreateVolumeRequestContent) (string, *catalog.VolumeInfo, error) { +func (r *ResourceVolume) DoCreate(ctx context.Context, _ *Engine, config *catalog.CreateVolumeRequestContent) (string, *catalog.VolumeInfo, error) { response, err := r.client.Volumes.Create(ctx, *config) if err != nil { return "", nil, err @@ -48,7 +48,7 @@ func (r *ResourceVolume) DoCreate(ctx context.Context, config *catalog.CreateVol return response.FullName, response, nil } -func (r *ResourceVolume) DoUpdate(ctx context.Context, id string, config *catalog.CreateVolumeRequestContent, _ *PlanEntry) (*catalog.VolumeInfo, error) { +func (r *ResourceVolume) DoUpdate(ctx context.Context, _ *Engine, id string, config *catalog.CreateVolumeRequestContent, _ *PlanEntry) (*catalog.VolumeInfo, error) { updateRequest := catalog.UpdateVolumeRequestContent{ Comment: config.Comment, Name: id, From 709e90e3a75a2da745914cb5bc58366323949762 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 29 May 2026 15:03:06 +0200 Subject: [PATCH 02/32] direct/testserver: improve app test realism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make testserver app DELETE asynchronous (sets DELETING state) to match real API behaviour. AppsGet auto-removes the app after returning it in DELETING state, so the next request sees it as gone. app_test.go: use real SDK calls (Create + DeleteByName) to put the app in DELETING state before testing DoCreate retry logic, instead of injecting state directly. RetriesWhenGetReturnsNotFound uses a one-shot POST override so GET returns 404 naturally without a custom GET handler. all_test.go: retry DoRead once after DoDelete to let async deletions (DELETING → gone) clear before asserting the resource is absent. Add Server.GetWorkspace to testserver for pre-seeding state in tests. Co-authored-by: Denis Bilenko --- bundle/direct/dresources/app_test.go | 99 +++++-------------------- libs/testserver/apps.go | 104 +++++++++++++-------------- libs/testserver/server.go | 6 ++ 3 files changed, 77 insertions(+), 132 deletions(-) diff --git a/bundle/direct/dresources/app_test.go b/bundle/direct/dresources/app_test.go index 70b23704733..ba64ba9475c 100644 --- a/bundle/direct/dresources/app_test.go +++ b/bundle/direct/dresources/app_test.go @@ -17,47 +17,6 @@ import ( // an app already exists but is in DELETING state. func TestAppDoCreate_RetriesWhenAppIsDeleting(t *testing.T) { server := testserver.New(t) - - createCallCount := 0 - getCallCount := 0 - - server.Handle("POST", "/api/2.0/apps", func(req testserver.Request) any { - createCallCount++ - if createCallCount == 1 { - return testserver.Response{ - StatusCode: 409, - Body: map[string]string{ - "error_code": "RESOURCE_ALREADY_EXISTS", - "message": "An app with the same name already exists.", - }, - } - } - return apps.App{ - Name: "test-app", - ComputeStatus: &apps.ComputeStatus{ - State: apps.ComputeStateActive, - }, - } - }) - - server.Handle("GET", "/api/2.0/apps/{name}", func(req testserver.Request) any { - getCallCount++ - if getCallCount == 1 { - return apps.App{ - Name: req.Vars["name"], - ComputeStatus: &apps.ComputeStatus{ - State: apps.ComputeStateDeleting, - }, - } - } - return apps.App{ - Name: req.Vars["name"], - ComputeStatus: &apps.ComputeStatus{ - State: apps.ComputeStateActive, - }, - } - }) - testserver.AddDefaultHandlers(server) client, err := databricks.NewWorkspaceClient(&databricks.Config{ @@ -66,14 +25,21 @@ func TestAppDoCreate_RetriesWhenAppIsDeleting(t *testing.T) { }) require.NoError(t, err) - r := (&ResourceApp{}).New(client) ctx := t.Context() + + // Create then delete an app to put it in DELETING state. + // The testserver's DELETE is asynchronous: it sets DELETING rather than + // removing immediately, so the retry create will find the app in that state. + _, err = client.Apps.Create(ctx, apps.CreateAppRequest{App: apps.App{Name: "test-app"}}) + require.NoError(t, err) + _, err = client.Apps.DeleteByName(ctx, "test-app") + require.NoError(t, err) + + r := (&ResourceApp{}).New(client) name, _, err := r.DoCreate(ctx, NewNopEngine(reflect.TypeFor[*AppState]()), &AppState{App: apps.App{Name: "test-app"}}) require.NoError(t, err) assert.Equal(t, "test-app", name) - assert.Equal(t, 2, createCallCount, "expected Create to be called twice (1 retry)") - assert.Equal(t, 2, getCallCount, "expected Get to be called twice: once to check app state, once by waitForApp") } // TestAppDoCreate_RetriesWhenGetReturnsNotFound verifies that DoCreate retries @@ -81,45 +47,20 @@ func TestAppDoCreate_RetriesWhenAppIsDeleting(t *testing.T) { func TestAppDoCreate_RetriesWhenGetReturnsNotFound(t *testing.T) { server := testserver.New(t) - createCallCount := 0 - getCallCount := 0 - + // Simulate a race: the app existed when Create was called (returns 409) but + // was deleted before the existence check (GET returns 404). The first POST + // returns 409 without storing anything so the standard GET handler returns + // 404 naturally, and the retry POST creates the app normally. + rejectedOnce := false server.Handle("POST", "/api/2.0/apps", func(req testserver.Request) any { - createCallCount++ - if createCallCount == 1 { + if !rejectedOnce { + rejectedOnce = true return testserver.Response{ StatusCode: 409, - Body: map[string]string{ - "error_code": "RESOURCE_ALREADY_EXISTS", - "message": "An app with the same name already exists.", - }, + Body: map[string]string{"error_code": "RESOURCE_ALREADY_EXISTS", "message": "An app with the same name already exists."}, } } - return apps.App{ - Name: "test-app", - ComputeStatus: &apps.ComputeStatus{ - State: apps.ComputeStateActive, - }, - } - }) - - server.Handle("GET", "/api/2.0/apps/{name}", func(req testserver.Request) any { - getCallCount++ - if getCallCount == 1 { - return testserver.Response{ - StatusCode: 404, - Body: map[string]string{ - "error_code": "RESOURCE_DOES_NOT_EXIST", - "message": "App not found.", - }, - } - } - return apps.App{ - Name: req.Vars["name"], - ComputeStatus: &apps.ComputeStatus{ - State: apps.ComputeStateActive, - }, - } + return req.Workspace.AppsUpsert(req, "") }) testserver.AddDefaultHandlers(server) @@ -136,8 +77,6 @@ func TestAppDoCreate_RetriesWhenGetReturnsNotFound(t *testing.T) { require.NoError(t, err) assert.Equal(t, "test-app", name) - assert.Equal(t, 2, createCallCount, "expected Create to be called twice") - assert.Equal(t, 2, getCallCount, "expected Get to be called twice: once to check app state, once by waitForApp") } func TestAppDoUpdate_UpdateMaskHasAllFields(t *testing.T) { diff --git a/libs/testserver/apps.go b/libs/testserver/apps.go index e767584e6ec..61ed463b48a 100644 --- a/libs/testserver/apps.go +++ b/libs/testserver/apps.go @@ -77,6 +77,58 @@ func (s *FakeWorkspace) AppsCreateUpdate(req Request, name string) Response { } } +// AppsGet returns the app, keeping DELETING resources visible so callers can +// observe transient state (matches the cloud DELETE lifecycle). +func (s *FakeWorkspace) AppsGet(name string) Response { + defer s.LockUnlock()() + + app, ok := s.Apps[name] + if !ok { + return Response{ + StatusCode: 404, + Body: map[string]string{"message": fmt.Sprintf("Resource apps.App not found: %v", name)}, + } + } + + return Response{Body: app} +} + +// AppsDelete simulates the real Apps DELETE lifecycle: the first DELETE flips +// the app into DELETING state (without removing it), and a second DELETE while +// still in DELETING returns 400 with the exact cloud error message. +func (s *FakeWorkspace) AppsDelete(name string) Response { + defer s.LockUnlock()() + + app, ok := s.Apps[name] + if !ok { + return Response{StatusCode: 404} + } + + if app.ComputeStatus != nil && app.ComputeStatus.State == apps.ComputeStateDeleting { + return Response{ + StatusCode: http.StatusBadRequest, + Body: map[string]string{ + "error_code": "BAD_REQUEST", + "message": fmt.Sprintf( + "Cannot delete app %s as it is not terminal with state DELETING, "+ + "and was updated less than 20 minutes ago. Please wait before trying again.", name), + }, + } + } + + app.ComputeStatus = &apps.ComputeStatus{ + State: apps.ComputeStateDeleting, + Message: "App is being deleted.", + } + app.AppStatus = &apps.ApplicationStatus{ + State: "UNAVAILABLE", + Message: appStatusUnavailableMessage, + } + s.Apps[name] = app + + return Response{} +} + func (s *FakeWorkspace) AppsGetUpdate(_ Request, name string) Response { defer s.LockUnlock()() @@ -195,58 +247,6 @@ func (s *FakeWorkspace) AppsStop(_ Request, name string) Response { return Response{Body: app} } -// AppsGet returns the app, keeping DELETING resources visible so callers can -// observe transient state (matches the cloud DELETE lifecycle). -func (s *FakeWorkspace) AppsGet(name string) Response { - defer s.LockUnlock()() - - app, ok := s.Apps[name] - if !ok { - return Response{ - StatusCode: 404, - Body: map[string]string{"message": fmt.Sprintf("Resource apps.App not found: %v", name)}, - } - } - - return Response{Body: app} -} - -// AppsDelete simulates the real Apps DELETE lifecycle: the first DELETE flips -// the app into DELETING state (without removing it), and a second DELETE while -// still in DELETING returns 400 with the exact cloud error message. -func (s *FakeWorkspace) AppsDelete(name string) Response { - defer s.LockUnlock()() - - app, ok := s.Apps[name] - if !ok { - return Response{StatusCode: 404} - } - - if app.ComputeStatus != nil && app.ComputeStatus.State == apps.ComputeStateDeleting { - return Response{ - StatusCode: http.StatusBadRequest, - Body: map[string]string{ - "error_code": "BAD_REQUEST", - "message": fmt.Sprintf( - "Cannot delete app %s as it is not terminal with state DELETING, "+ - "and was updated less than 20 minutes ago. Please wait before trying again.", name), - }, - } - } - - app.ComputeStatus = &apps.ComputeStatus{ - State: apps.ComputeStateDeleting, - Message: "App is being deleted.", - } - app.AppStatus = &apps.ApplicationStatus{ - State: "UNAVAILABLE", - Message: appStatusUnavailableMessage, - } - s.Apps[name] = app - - return Response{} -} - func (s *FakeWorkspace) AppsUpsert(req Request, name string) Response { var app apps.App diff --git a/libs/testserver/server.go b/libs/testserver/server.go index 5ae3141bfd2..9437aebcf15 100644 --- a/libs/testserver/server.go +++ b/libs/testserver/server.go @@ -338,6 +338,12 @@ func workspaceKeyForToken(token string) string { return token } +// GetWorkspace returns (creating if necessary) the FakeWorkspace for the given token. +// Use this in tests to pre-seed state before making requests. +func (s *Server) GetWorkspace(token string) *FakeWorkspace { + return s.getWorkspaceForToken(token) +} + func (s *Server) getWorkspaceForToken(token string) *FakeWorkspace { if token == "" { return nil From 53c27d0bb2b2b3f06267c30b7a3a77f4d3868220 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 1 Jun 2026 10:52:36 +0200 Subject: [PATCH 03/32] testserver: remove unused GetWorkspace method Co-authored-by: Denis Bilenko --- libs/testserver/server.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/libs/testserver/server.go b/libs/testserver/server.go index 9437aebcf15..5ae3141bfd2 100644 --- a/libs/testserver/server.go +++ b/libs/testserver/server.go @@ -338,12 +338,6 @@ func workspaceKeyForToken(token string) string { return token } -// GetWorkspace returns (creating if necessary) the FakeWorkspace for the given token. -// Use this in tests to pre-seed state before making requests. -func (s *Server) GetWorkspace(token string) *FakeWorkspace { - return s.getWorkspaceForToken(token) -} - func (s *Server) getWorkspaceForToken(token string) *FakeWorkspace { if token == "" { return nil From 532c07680b447159f9fce415e5ffa1299fbff759 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 1 Jun 2026 15:05:38 +0200 Subject: [PATCH 04/32] direct: simplify Engine API: SaveState(id, state) replaces SetID+SaveState Collapse the two-step engine.SetID(id) / engine.SaveState(state) pattern into a single engine.SaveState(id, state) call. The Engine still tracks the id internally and panics if a subsequent call passes a different id (guards against bugs). Also extract the polling loop from vector_search_index.DoCreate into a private waitForIndexReady helper so that the unchanged DoDelete stays between the two functions and does not appear in the diff. Co-authored-by: Denis Bilenko --- bundle/direct/apply.go | 2 - bundle/direct/dresources/adapter.go | 2 +- bundle/direct/dresources/app.go | 4 +- bundle/direct/dresources/cluster.go | 3 +- bundle/direct/dresources/database_instance.go | 3 +- bundle/direct/dresources/engine.go | 19 +++----- .../dresources/model_serving_endpoint.go | 3 +- .../dresources/vector_search_endpoint.go | 3 +- .../direct/dresources/vector_search_index.go | 43 +++++++++++-------- 9 files changed, 40 insertions(+), 42 deletions(-) diff --git a/bundle/direct/apply.go b/bundle/direct/apply.go index c125bdd4ba1..3e4711df82a 100644 --- a/bundle/direct/apply.go +++ b/bundle/direct/apply.go @@ -132,8 +132,6 @@ func (d *DeploymentUnit) Update(ctx context.Context, db *dstate.DeploymentState, engine := dresources.NewEngine(d.Adapter.StateType(), func(_ string, x any) error { return db.SaveState(d.ResourceKey, id, x, d.DependsOn) }) - engine.SetID(id) - remoteState, err := retryOnTransient(ctx, func() (any, error) { return d.Adapter.DoUpdate(ctx, engine, id, newState, planEntry) }) diff --git a/bundle/direct/dresources/adapter.go b/bundle/direct/dresources/adapter.go index ba68692722c..e8a15334a0c 100644 --- a/bundle/direct/dresources/adapter.go +++ b/bundle/direct/dresources/adapter.go @@ -53,7 +53,7 @@ type IResource interface { // DoCreate creates a new resource from the newState. Returns id of the resource and optionally remote state. // If remote state is available as part of the operation, return it; otherwise return nil. - // Call engine.SetID then engine.SaveState to persist intermediate state before long-running waits. + // Call engine.SaveState(id, state) to persist intermediate state before long-running waits. // Example: func (r *ResourceVolume) DoCreate(ctx context.Context, _ *Engine, newState *catalog.CreateVolumeRequestContent) (string, *catalog.VolumeInfo, error) DoCreate(ctx context.Context, engine *Engine, newState any) (id string, remoteState any, e error) diff --git a/bundle/direct/dresources/app.go b/bundle/direct/dresources/app.go index 372dcab43f5..a2323eb2e7c 100644 --- a/bundle/direct/dresources/app.go +++ b/bundle/direct/dresources/app.go @@ -156,8 +156,8 @@ func (r *ResourceApp) DoCreate(ctx context.Context, engine *Engine, config *AppS // Save state as soon as the app exists so it is not orphaned if the wait or // lifecycle management is interrupted. - engine.SetID(app.Name) - if err := engine.SaveState(config); err != nil { + + if err := engine.SaveState(app.Name, config); err != nil { return "", nil, err } diff --git a/bundle/direct/dresources/cluster.go b/bundle/direct/dresources/cluster.go index 2cc505418f5..fafddc29b43 100644 --- a/bundle/direct/dresources/cluster.go +++ b/bundle/direct/dresources/cluster.go @@ -174,8 +174,7 @@ func (r *ResourceCluster) DoCreate(ctx context.Context, engine *Engine, config * // Save state immediately after the cluster is created so it is not orphaned // if the subsequent wait or terminate is interrupted. - engine.SetID(id) - if err := engine.SaveState(config); err != nil { + if err := engine.SaveState(id, config); err != nil { return "", nil, err } diff --git a/bundle/direct/dresources/database_instance.go b/bundle/direct/dresources/database_instance.go index 35251eb526c..3f0dc827fab 100644 --- a/bundle/direct/dresources/database_instance.go +++ b/bundle/direct/dresources/database_instance.go @@ -36,8 +36,7 @@ func (d *ResourceDatabaseInstance) DoCreate(ctx context.Context, engine *Engine, // Save state immediately after the instance is created so it is not orphaned // if the subsequent wait is interrupted. - engine.SetID(id) - if err := engine.SaveState(config); err != nil { + if err := engine.SaveState(id, config); err != nil { return "", nil, err } diff --git a/bundle/direct/dresources/engine.go b/bundle/direct/dresources/engine.go index c4c8be8e25f..1e9bdd7b28c 100644 --- a/bundle/direct/dresources/engine.go +++ b/bundle/direct/dresources/engine.go @@ -1,7 +1,6 @@ package dresources import ( - "errors" "fmt" "reflect" ) @@ -26,18 +25,14 @@ func NewNopEngine(stateType reflect.Type) *Engine { return NewEngine(stateType, func(_ string, _ any) error { return nil }) } -// SetID sets the resource id for subsequent SaveState calls. -// Must be called before SaveState during DoCreate; for DoUpdate the Engine is -// pre-configured with the existing id. -func (e *Engine) SetID(id string) { - e.id = id -} - -// SaveState saves the resource state. x must be of the same pointer-to-struct -// type as the resource's state type. Returns an error if SetID was not called. -func (e *Engine) SaveState(x any) error { +// SaveState saves the resource state. id must be the resource's identifier; on +// the first call it is recorded, and subsequent calls panic if a different id is +// passed. x must be a pointer to the same struct type as the resource's state. +func (e *Engine) SaveState(id string, x any) error { if e.id == "" { - return errors.New("SaveState: id not set, call SetID first") + e.id = id + } else if e.id != id { + panic(fmt.Sprintf("SaveState: id mismatch: expected %q, got %q", e.id, id)) } xt := reflect.TypeOf(x) if xt != e.stateType { diff --git a/bundle/direct/dresources/model_serving_endpoint.go b/bundle/direct/dresources/model_serving_endpoint.go index 0ccdc734b65..b589852dedf 100644 --- a/bundle/direct/dresources/model_serving_endpoint.go +++ b/bundle/direct/dresources/model_serving_endpoint.go @@ -160,8 +160,7 @@ func (r *ResourceModelServingEndpoint) DoCreate(ctx context.Context, engine *Eng // Save state immediately after the endpoint is created so it is not orphaned // if the subsequent wait is interrupted. - engine.SetID(id) - if err := engine.SaveState(config); err != nil { + if err := engine.SaveState(id, config); err != nil { return "", nil, err } diff --git a/bundle/direct/dresources/vector_search_endpoint.go b/bundle/direct/dresources/vector_search_endpoint.go index fb599a1ba8e..7108784005b 100644 --- a/bundle/direct/dresources/vector_search_endpoint.go +++ b/bundle/direct/dresources/vector_search_endpoint.go @@ -89,8 +89,7 @@ func (r *ResourceVectorSearchEndpoint) DoCreate(ctx context.Context, engine *Eng // Save state immediately after the endpoint is created so it is not orphaned // if the subsequent wait is interrupted. - engine.SetID(id) - if err := engine.SaveState(config); err != nil { + if err := engine.SaveState(id, config); err != nil { return "", nil, err } diff --git a/bundle/direct/dresources/vector_search_index.go b/bundle/direct/dresources/vector_search_index.go index 389cd26e21f..87a8bde964d 100644 --- a/bundle/direct/dresources/vector_search_index.go +++ b/bundle/direct/dresources/vector_search_index.go @@ -145,15 +145,34 @@ func (r *ResourceVectorSearchIndex) DoCreate(ctx context.Context, engine *Engine // Save state immediately after the index is created (endpoint UUID now set) so it // is not orphaned if the subsequent provisioning wait is interrupted. - engine.SetID(config.Name) - if err := engine.SaveState(config); err != nil { + if err := engine.SaveState(config.Name, config); err != nil { return "", nil, err } - // CreateIndex returns immediately; poll until the embedding pipeline is ready so - // dependent resources and the next plan see a usable index. + remote, err := r.waitForIndexReady(ctx, config.Name, endpointUuid) + if err != nil { + return "", nil, err + } + return config.Name, remote, nil +} + +// No DoUpdate: vector search indexes have no update API. All SDK fields are +// declared in resources.yml under recreate_on_changes or ignore_remote_changes. +// If a future SDK bump adds a new field that isn't classified, the framework +// rejects the resulting Update plan at bundle_plan.go (see also the reflection +// test in vector_search_index_test.go which catches it earlier at unit-test time). + +func (r *ResourceVectorSearchIndex) DoDelete(ctx context.Context, id string, _ *VectorSearchIndexState) error { + return r.client.VectorSearchIndexes.DeleteIndexByIndexName(ctx, id) +} + +// waitForIndexReady polls GetIndex until Status.Ready=true. CreateIndex returns +// immediately with metadata of an index whose embedding pipeline is still +// provisioning; queries against an index that isn't ready fail. Blocking here +// lets dependent resources (and the next plan) see a usable index. +func (r *ResourceVectorSearchIndex) waitForIndexReady(ctx context.Context, id, endpointUuid string) (*VectorSearchIndexRemote, error) { index, err := retries.Poll(ctx, createIndexTimeout, func() (*vectorsearch.VectorIndex, *retries.Err) { - idx, getErr := r.client.VectorSearchIndexes.GetIndexByIndexName(ctx, config.Name) + idx, getErr := r.client.VectorSearchIndexes.GetIndexByIndexName(ctx, id) if getErr != nil { return nil, retries.Halt(getErr) } @@ -167,9 +186,9 @@ func (r *ResourceVectorSearchIndex) DoCreate(ctx context.Context, engine *Engine return idx, nil }) if err != nil { - return "", nil, err + return nil, err } - return config.Name, &VectorSearchIndexRemote{VectorIndex: *index, EndpointUuid: endpointUuid}, nil + return &VectorSearchIndexRemote{VectorIndex: *index, EndpointUuid: endpointUuid}, nil } // createIndex calls CreateIndex, retrying while the backend still reports the @@ -208,16 +227,6 @@ func isIndexPendingDeletion(err error) bool { return ok && strings.Contains(apiErr.Message, "pending deletion") } -// No DoUpdate: vector search indexes have no update API. All SDK fields are -// declared in resources.yml under recreate_on_changes or ignore_remote_changes. -// If a future SDK bump adds a new field that isn't classified, the framework -// rejects the resulting Update plan at bundle_plan.go (see also the reflection -// test in vector_search_index_test.go which catches it earlier at unit-test time). - -func (r *ResourceVectorSearchIndex) DoDelete(ctx context.Context, id string, _ *VectorSearchIndexState) error { - return r.client.VectorSearchIndexes.DeleteIndexByIndexName(ctx, id) -} - // WaitAfterDelete polls GetIndex until it returns 404. The DELETE call is // asynchronous, so without this a `bundle destroy` would report success while // the index is still being torn down. The framework calls this after dropping From 290095ba38a1766e907bf8d6f0cb65466c7149aa Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 1 Jun 2026 15:53:22 +0200 Subject: [PATCH 05/32] direct: Engine.SaveState takes ctx, returns void; logs I/O failures internally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resource implementations cannot recover from a state-persistence failure — the resource already exists on the server and aborting would not undo its creation. Log the error via logdiag instead of propagating it, and drop the error return so call sites are a single statement. Co-authored-by: Denis Bilenko --- bundle/direct/dresources/adapter.go | 2 +- bundle/direct/dresources/app.go | 4 +--- bundle/direct/dresources/cluster.go | 4 +--- bundle/direct/dresources/database_instance.go | 4 +--- bundle/direct/dresources/engine.go | 13 ++++++++++--- bundle/direct/dresources/model_serving_endpoint.go | 4 +--- bundle/direct/dresources/vector_search_endpoint.go | 4 +--- bundle/direct/dresources/vector_search_index.go | 4 +--- 8 files changed, 17 insertions(+), 22 deletions(-) diff --git a/bundle/direct/dresources/adapter.go b/bundle/direct/dresources/adapter.go index e8a15334a0c..b8fbab72946 100644 --- a/bundle/direct/dresources/adapter.go +++ b/bundle/direct/dresources/adapter.go @@ -53,7 +53,7 @@ type IResource interface { // DoCreate creates a new resource from the newState. Returns id of the resource and optionally remote state. // If remote state is available as part of the operation, return it; otherwise return nil. - // Call engine.SaveState(id, state) to persist intermediate state before long-running waits. + // Call engine.SaveState(ctx, id, state) to persist intermediate state before long-running waits. // Example: func (r *ResourceVolume) DoCreate(ctx context.Context, _ *Engine, newState *catalog.CreateVolumeRequestContent) (string, *catalog.VolumeInfo, error) DoCreate(ctx context.Context, engine *Engine, newState any) (id string, remoteState any, e error) diff --git a/bundle/direct/dresources/app.go b/bundle/direct/dresources/app.go index a2323eb2e7c..75e80e41736 100644 --- a/bundle/direct/dresources/app.go +++ b/bundle/direct/dresources/app.go @@ -157,9 +157,7 @@ func (r *ResourceApp) DoCreate(ctx context.Context, engine *Engine, config *AppS // Save state as soon as the app exists so it is not orphaned if the wait or // lifecycle management is interrupted. - if err := engine.SaveState(app.Name, config); err != nil { - return "", nil, err - } + engine.SaveState(ctx, app.Name, config) remote, err := r.waitForApp(ctx, r.client, config.Name) if err != nil { diff --git a/bundle/direct/dresources/cluster.go b/bundle/direct/dresources/cluster.go index fafddc29b43..7db37f56097 100644 --- a/bundle/direct/dresources/cluster.go +++ b/bundle/direct/dresources/cluster.go @@ -174,9 +174,7 @@ func (r *ResourceCluster) DoCreate(ctx context.Context, engine *Engine, config * // Save state immediately after the cluster is created so it is not orphaned // if the subsequent wait or terminate is interrupted. - if err := engine.SaveState(id, config); err != nil { - return "", nil, err - } + engine.SaveState(ctx, id, config) // Always wait for RUNNING first: clusters start in PENDING state and must be polled. _, err = r.client.Clusters.WaitGetClusterRunning(ctx, id, clusterWaitTimeout, nil) diff --git a/bundle/direct/dresources/database_instance.go b/bundle/direct/dresources/database_instance.go index 3f0dc827fab..c66a0bd092a 100644 --- a/bundle/direct/dresources/database_instance.go +++ b/bundle/direct/dresources/database_instance.go @@ -36,9 +36,7 @@ func (d *ResourceDatabaseInstance) DoCreate(ctx context.Context, engine *Engine, // Save state immediately after the instance is created so it is not orphaned // if the subsequent wait is interrupted. - if err := engine.SaveState(id, config); err != nil { - return "", nil, err - } + engine.SaveState(ctx, id, config) waiterObj := &database.WaitGetDatabaseInstanceDatabaseAvailable[database.DatabaseInstance]{ Response: config, diff --git a/bundle/direct/dresources/engine.go b/bundle/direct/dresources/engine.go index 1e9bdd7b28c..46deca6600d 100644 --- a/bundle/direct/dresources/engine.go +++ b/bundle/direct/dresources/engine.go @@ -1,8 +1,11 @@ package dresources import ( + "context" "fmt" "reflect" + + "github.com/databricks/cli/libs/logdiag" ) // Engine provides state persistence to resource implementations. @@ -28,7 +31,9 @@ func NewNopEngine(stateType reflect.Type) *Engine { // SaveState saves the resource state. id must be the resource's identifier; on // the first call it is recorded, and subsequent calls panic if a different id is // passed. x must be a pointer to the same struct type as the resource's state. -func (e *Engine) SaveState(id string, x any) error { +// Failures to persist state are logged but do not abort the deployment — the +// resource already exists and aborting would not undo its creation. +func (e *Engine) SaveState(ctx context.Context, id string, x any) { if e.id == "" { e.id = id } else if e.id != id { @@ -36,7 +41,9 @@ func (e *Engine) SaveState(id string, x any) error { } xt := reflect.TypeOf(x) if xt != e.stateType { - return fmt.Errorf("SaveState: type mismatch: expected %v, got %v", e.stateType, xt) + panic(fmt.Sprintf("SaveState: type mismatch: expected %v, got %v", e.stateType, xt)) + } + if err := e.saveFunc(e.id, x); err != nil { + logdiag.LogError(ctx, err) } - return e.saveFunc(e.id, x) } diff --git a/bundle/direct/dresources/model_serving_endpoint.go b/bundle/direct/dresources/model_serving_endpoint.go index b589852dedf..5a5db425d91 100644 --- a/bundle/direct/dresources/model_serving_endpoint.go +++ b/bundle/direct/dresources/model_serving_endpoint.go @@ -160,9 +160,7 @@ func (r *ResourceModelServingEndpoint) DoCreate(ctx context.Context, engine *Eng // Save state immediately after the endpoint is created so it is not orphaned // if the subsequent wait is interrupted. - if err := engine.SaveState(id, config); err != nil { - return "", nil, err - } + engine.SaveState(ctx, id, config) remote, err := r.waitForEndpointReady(ctx, config.Name) return id, remote, err diff --git a/bundle/direct/dresources/vector_search_endpoint.go b/bundle/direct/dresources/vector_search_endpoint.go index 7108784005b..9f7518cc937 100644 --- a/bundle/direct/dresources/vector_search_endpoint.go +++ b/bundle/direct/dresources/vector_search_endpoint.go @@ -89,9 +89,7 @@ func (r *ResourceVectorSearchEndpoint) DoCreate(ctx context.Context, engine *Eng // Save state immediately after the endpoint is created so it is not orphaned // if the subsequent wait is interrupted. - if err := engine.SaveState(id, config); err != nil { - return "", nil, err - } + engine.SaveState(ctx, id, config) info, err := r.client.VectorSearchEndpoints.WaitGetEndpointVectorSearchEndpointOnline(ctx, config.Name, 60*time.Minute, nil) if err != nil { diff --git a/bundle/direct/dresources/vector_search_index.go b/bundle/direct/dresources/vector_search_index.go index 87a8bde964d..98ff5be1fc6 100644 --- a/bundle/direct/dresources/vector_search_index.go +++ b/bundle/direct/dresources/vector_search_index.go @@ -145,9 +145,7 @@ func (r *ResourceVectorSearchIndex) DoCreate(ctx context.Context, engine *Engine // Save state immediately after the index is created (endpoint UUID now set) so it // is not orphaned if the subsequent provisioning wait is interrupted. - if err := engine.SaveState(config.Name, config); err != nil { - return "", nil, err - } + engine.SaveState(ctx, config.Name, config) remote, err := r.waitForIndexReady(ctx, config.Name, endpointUuid) if err != nil { From f45ffe08e49290a2d919c2707d1a0c8317761872 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 1 Jun 2026 17:13:31 +0200 Subject: [PATCH 06/32] direct: save state before wait in postgres and dashboard resources All six resources with a gap between resource creation and a subsequent long-running wait now call engine.SaveState immediately after the create API returns, using waiter.Name() (available before Wait()) for the postgres resources and createResp.DashboardId for the dashboard. This ensures an interrupted deployment leaves a tracked resource rather than an orphan the next plan must rediscover from remote state. Co-authored-by: Denis Bilenko --- bundle/direct/dresources/dashboard.go | 3 ++- bundle/direct/dresources/postgres_branch.go | 3 ++- bundle/direct/dresources/postgres_catalog.go | 3 ++- bundle/direct/dresources/postgres_endpoint.go | 3 ++- bundle/direct/dresources/postgres_project.go | 3 ++- bundle/direct/dresources/postgres_synced_table.go | 3 ++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/bundle/direct/dresources/dashboard.go b/bundle/direct/dresources/dashboard.go index 6bf959b2c21..06b764e0da3 100644 --- a/bundle/direct/dresources/dashboard.go +++ b/bundle/direct/dresources/dashboard.go @@ -305,7 +305,7 @@ func responseToState(createOrUpdateResp *dashboards.Dashboard, publishResp *dash } } -func (r *ResourceDashboard) DoCreate(ctx context.Context, _ *Engine, config *DashboardState) (string, *DashboardState, error) { +func (r *ResourceDashboard) DoCreate(ctx context.Context, engine *Engine, config *DashboardState) (string, *DashboardState, error) { dashboard, err := prepareDashboardRequest(config) if err != nil { return "", nil, err @@ -340,6 +340,7 @@ func (r *ResourceDashboard) DoCreate(ctx context.Context, _ *Engine, config *Das // Persist the etag in state. config.Etag = createResp.Etag + engine.SaveState(ctx, createResp.DashboardId, config) var publishResp *dashboards.PublishedDashboard // Note, today config.Published is always true (we do not have this field in input config). diff --git a/bundle/direct/dresources/postgres_branch.go b/bundle/direct/dresources/postgres_branch.go index 88cfb884aa3..fc15ac85529 100644 --- a/bundle/direct/dresources/postgres_branch.go +++ b/bundle/direct/dresources/postgres_branch.go @@ -106,7 +106,7 @@ func (r *ResourcePostgresBranch) DoRead(ctx context.Context, id string) (*Postgr return makePostgresBranchRemote(branch), nil } -func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, _ *Engine, config *PostgresBranchState) (string, *PostgresBranchRemote, error) { +func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, engine *Engine, config *PostgresBranchState) (string, *PostgresBranchRemote, error) { waiter, err := r.client.Postgres.CreateBranch(ctx, postgres.CreateBranchRequest{ BranchId: config.BranchId, Parent: config.Parent, @@ -129,6 +129,7 @@ func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, _ *Engine, config if err != nil { return "", nil, err } + engine.SaveState(ctx, waiter.Name(), config) // Wait for the branch to be ready (long-running operation) result, err := waiter.Wait(ctx) diff --git a/bundle/direct/dresources/postgres_catalog.go b/bundle/direct/dresources/postgres_catalog.go index 279f38d2fe2..680a3c10e4c 100644 --- a/bundle/direct/dresources/postgres_catalog.go +++ b/bundle/direct/dresources/postgres_catalog.go @@ -91,7 +91,7 @@ func (r *ResourcePostgresCatalog) DoRead(ctx context.Context, id string) (*Postg return makePostgresCatalogRemote(catalog), nil } -func (r *ResourcePostgresCatalog) DoCreate(ctx context.Context, _ *Engine, config *PostgresCatalogState) (string, *PostgresCatalogRemote, error) { +func (r *ResourcePostgresCatalog) DoCreate(ctx context.Context, engine *Engine, config *PostgresCatalogState) (string, *PostgresCatalogRemote, error) { waiter, err := r.client.Postgres.CreateCatalog(ctx, postgres.CreateCatalogRequest{ CatalogId: config.CatalogId, Catalog: postgres.Catalog{ @@ -110,6 +110,7 @@ func (r *ResourcePostgresCatalog) DoCreate(ctx context.Context, _ *Engine, confi if err != nil { return "", nil, err } + engine.SaveState(ctx, waiter.Name(), config) result, err := waiter.Wait(ctx) if err != nil { diff --git a/bundle/direct/dresources/postgres_endpoint.go b/bundle/direct/dresources/postgres_endpoint.go index 18bc8c5a6d9..3ffd3d677e0 100644 --- a/bundle/direct/dresources/postgres_endpoint.go +++ b/bundle/direct/dresources/postgres_endpoint.go @@ -137,7 +137,7 @@ func (r *ResourcePostgresEndpoint) waitForReconciliation(ctx context.Context, na } } -func (r *ResourcePostgresEndpoint) DoCreate(ctx context.Context, _ *Engine, config *PostgresEndpointState) (string, *PostgresEndpointRemote, error) { +func (r *ResourcePostgresEndpoint) DoCreate(ctx context.Context, engine *Engine, config *PostgresEndpointState) (string, *PostgresEndpointRemote, error) { waiter, err := r.client.Postgres.CreateEndpoint(ctx, postgres.CreateEndpointRequest{ EndpointId: config.EndpointId, Parent: config.Parent, @@ -160,6 +160,7 @@ func (r *ResourcePostgresEndpoint) DoCreate(ctx context.Context, _ *Engine, conf if err != nil { return "", nil, err } + engine.SaveState(ctx, waiter.Name(), config) // Wait for the operation to complete result, err := waiter.Wait(ctx) diff --git a/bundle/direct/dresources/postgres_project.go b/bundle/direct/dresources/postgres_project.go index 42410f9705e..c7d63158975 100644 --- a/bundle/direct/dresources/postgres_project.go +++ b/bundle/direct/dresources/postgres_project.go @@ -102,7 +102,7 @@ func (r *ResourcePostgresProject) DoRead(ctx context.Context, id string) (*Postg return makePostgresProjectRemote(project), nil } -func (r *ResourcePostgresProject) DoCreate(ctx context.Context, _ *Engine, config *PostgresProjectState) (string, *PostgresProjectRemote, error) { +func (r *ResourcePostgresProject) DoCreate(ctx context.Context, engine *Engine, config *PostgresProjectState) (string, *PostgresProjectRemote, error) { waiter, err := r.client.Postgres.CreateProject(ctx, postgres.CreateProjectRequest{ ProjectId: config.ProjectId, Project: postgres.Project{ @@ -125,6 +125,7 @@ func (r *ResourcePostgresProject) DoCreate(ctx context.Context, _ *Engine, confi if err != nil { return "", nil, err } + engine.SaveState(ctx, waiter.Name(), config) // Wait for the project to be ready (long-running operation) result, err := waiter.Wait(ctx) diff --git a/bundle/direct/dresources/postgres_synced_table.go b/bundle/direct/dresources/postgres_synced_table.go index 28013b07224..02e4c81d171 100644 --- a/bundle/direct/dresources/postgres_synced_table.go +++ b/bundle/direct/dresources/postgres_synced_table.go @@ -91,7 +91,7 @@ func (r *ResourcePostgresSyncedTable) DoRead(ctx context.Context, id string) (*P return makePostgresSyncedTableRemote(syncedTable), nil } -func (r *ResourcePostgresSyncedTable) DoCreate(ctx context.Context, _ *Engine, config *PostgresSyncedTableState) (string, *PostgresSyncedTableRemote, error) { +func (r *ResourcePostgresSyncedTable) DoCreate(ctx context.Context, engine *Engine, config *PostgresSyncedTableState) (string, *PostgresSyncedTableRemote, error) { waiter, err := r.client.Postgres.CreateSyncedTable(ctx, postgres.CreateSyncedTableRequest{ SyncedTableId: config.SyncedTableId, SyncedTable: postgres.SyncedTable{ @@ -109,6 +109,7 @@ func (r *ResourcePostgresSyncedTable) DoCreate(ctx context.Context, _ *Engine, c if err != nil { return "", nil, err } + engine.SaveState(ctx, waiter.Name(), config) result, err := waiter.Wait(ctx) if err != nil { From 6aacc6d3627ff9f530091784697ce521150b3c77 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 2 Jun 2026 15:31:20 +0200 Subject: [PATCH 07/32] direct: save state before publishing dashboard; add acceptance tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dashboard.DoCreate now calls engine.SaveState immediately after the dashboard is created (with etag persisted), before publishDashboard. A failed publish leaves the draft tracked in state rather than orphaned; the next deploy finds it via DoRead and re-publishes via DoUpdate without recreating the dashboard. The old trash-on-publish-failure cleanup is removed — it was a fragile workaround for the lack of state persistence and is now unnecessary. Acceptance tests: - publish-failure-cleans-up-dashboard: updated to reflect the new behavior per engine (direct: draft persists with URL; terraform: existing behavior, cleaned up). Output files split to per-engine variants (out.summary.*.txt, out.dashboardrequests.*.txt). - publish-failure-retry (new, direct only): verifies end-to-end that a transient publish failure leaves the draft in state (summary shows URL, plan detects diff), and the subsequent deploy re-publishes without issuing a CREATE call. Co-authored-by: Denis Bilenko --- .../out.dashboardrequests.direct.txt | 39 +++++++++++ ...xt => out.dashboardrequests.terraform.txt} | 0 .../out.deploy.direct.txt | 1 + .../out.summary.direct.txt | 12 ++++ .../out.summary.terraform.txt | 12 ++++ .../output.txt | 12 ---- .../script | 8 +-- .../dashboard.lvdash.json | 1 + .../publish-failure-retry/databricks.yml.tmpl | 9 +++ .../publish-failure-retry/out.test.toml | 4 ++ .../publish-failure-retry/output.txt | 64 +++++++++++++++++++ .../dashboards/publish-failure-retry/script | 30 +++++++++ .../publish-failure-retry/test.toml | 12 ++++ bundle/direct/dresources/dashboard.go | 13 +--- 14 files changed, 191 insertions(+), 26 deletions(-) create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.dashboardrequests.direct.txt rename acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/{out.dashboardrequests.txt => out.dashboardrequests.terraform.txt} (100%) create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.summary.direct.txt create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.summary.terraform.txt create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-retry/dashboard.lvdash.json create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-retry/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-retry/out.test.toml create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-retry/script create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml diff --git a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.dashboardrequests.direct.txt b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.dashboardrequests.direct.txt new file mode 100644 index 00000000000..3e94771bdac --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.dashboardrequests.direct.txt @@ -0,0 +1,39 @@ +{ + "method": "POST", + "path": "/api/2.0/workspace/mkdirs", + "body": { + "path": "/Workspace/Users/[USERNAME]/.bundle/publish-failure-cleans-up-dashboard/default/artifacts/.internal" + } +} +{ + "method": "POST", + "path": "/api/2.0/workspace/mkdirs", + "body": { + "path": "/Workspace/Users/[USERNAME]/.bundle/publish-failure-cleans-up-dashboard/default/files" + } +} +{ + "method": "POST", + "path": "/api/2.0/workspace/mkdirs", + "body": { + "path": "/Workspace/Users/[USERNAME]/.bundle/publish-failure-cleans-up-dashboard/default/resources" + } +} +{ + "method": "POST", + "path": "/api/2.0/lakeview/dashboards", + "body": { + "display_name": "my dashboard", + "parent_path": "/Workspace/Users/[USERNAME]/.bundle/publish-failure-cleans-up-dashboard/default/resources", + "serialized_dashboard": "{\"pages\":[{\"name\":\"test-page\",\"displayName\":\"Test Dashboard\"}]}\n", + "warehouse_id": "doesnotexist" + } +} +{ + "method": "POST", + "path": "/api/2.0/lakeview/dashboards/[DASHBOARD_ID]/published", + "body": { + "embed_credentials": false, + "warehouse_id": "doesnotexist" + } +} diff --git a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.dashboardrequests.txt b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.dashboardrequests.terraform.txt similarity index 100% rename from acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.dashboardrequests.txt rename to acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.dashboardrequests.terraform.txt diff --git a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.deploy.direct.txt b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.deploy.direct.txt index 705bd09cb32..84918b848bf 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.deploy.direct.txt +++ b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.deploy.direct.txt @@ -9,5 +9,6 @@ HTTP Status: 400 Bad Request API error_code: RESOURCE_DOES_NOT_EXIST API message: Warehouse doesnotexist does not exist +Updating deployment state... Exit code: 1 diff --git a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.summary.direct.txt b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.summary.direct.txt new file mode 100644 index 00000000000..52c168b5302 --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.summary.direct.txt @@ -0,0 +1,12 @@ + +>>> [CLI] bundle summary +Name: publish-failure-cleans-up-dashboard +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/publish-failure-cleans-up-dashboard/default +Resources: + Dashboards: + dashboard1: + Name: my dashboard + URL: [DATABRICKS_URL]/dashboardsv3/[DASHBOARD_ID]/published?o=[NUMID] diff --git a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.summary.terraform.txt b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.summary.terraform.txt new file mode 100644 index 00000000000..37d00329c77 --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.summary.terraform.txt @@ -0,0 +1,12 @@ + +>>> [CLI] bundle summary +Name: publish-failure-cleans-up-dashboard +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/publish-failure-cleans-up-dashboard/default +Resources: + Dashboards: + dashboard1: + Name: my dashboard + URL: (not deployed) diff --git a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/output.txt b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/output.txt index 37d00329c77..e69de29bb2d 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/output.txt +++ b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/output.txt @@ -1,12 +0,0 @@ - ->>> [CLI] bundle summary -Name: publish-failure-cleans-up-dashboard -Target: default -Workspace: - User: [USERNAME] - Path: /Workspace/Users/[USERNAME]/.bundle/publish-failure-cleans-up-dashboard/default -Resources: - Dashboards: - dashboard1: - Name: my dashboard - URL: (not deployed) diff --git a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/script b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/script index 693a4320211..e8062b99e6e 100755 --- a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/script +++ b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/script @@ -4,9 +4,9 @@ envsubst < databricks.yml.tmpl > databricks.yml # Deploy the dashboard. The dashboard will be created but publish will fail because the warehouse does not exist. errcode trace $CLI bundle deploy &> out.deploy.$DATABRICKS_BUNDLE_ENGINE.txt -trace $CLI bundle summary +# After publish failure the dashboard draft should be in state (direct) or cleaned up (terraform). +trace $CLI bundle summary &>> out.summary.$DATABRICKS_BUNDLE_ENGINE.txt -# API should record a DELETE call to clean up the draft dashboard that was not published. -# Request sequence is identical across terraform and direct modes. +# API request sequence differs between engines (direct: no DELETE; terraform: DELETE to clean up). unset MSYS_NO_PATHCONV -print_requests.py //lakeview/dashboards //workspace/mkdirs > out.dashboardrequests.txt +print_requests.py //lakeview/dashboards //workspace/mkdirs > out.dashboardrequests.$DATABRICKS_BUNDLE_ENGINE.txt diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/dashboard.lvdash.json b/acceptance/bundle/resources/dashboards/publish-failure-retry/dashboard.lvdash.json new file mode 100644 index 00000000000..0bfc5797ff0 --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/dashboard.lvdash.json @@ -0,0 +1 @@ +{"pages":[{"name":"test-page","displayName":"Test Dashboard"}]} diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/databricks.yml.tmpl b/acceptance/bundle/resources/dashboards/publish-failure-retry/databricks.yml.tmpl new file mode 100644 index 00000000000..553305fbfde --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/databricks.yml.tmpl @@ -0,0 +1,9 @@ +bundle: + name: publish-failure-retry + +resources: + dashboards: + dashboard1: + display_name: my dashboard + warehouse_id: someid + file_path: ./dashboard.lvdash.json diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/out.test.toml b/acceptance/bundle/resources/dashboards/publish-failure-retry/out.test.toml new file mode 100644 index 00000000000..a29f11b9ab2 --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/out.test.toml @@ -0,0 +1,4 @@ +Local = true +Cloud = false +RequiresWarehouse = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt b/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt new file mode 100644 index 00000000000..05fd57f6c99 --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt @@ -0,0 +1,64 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/publish-failure-retry/default/files... +Deploying resources... +Error: cannot create resources.dashboards.dashboard1: Fault injected by test. (400 INJECTED) + +Endpoint: POST [DATABRICKS_URL]/api/2.0/lakeview/dashboards/[DASHBOARD_ID]/published +HTTP Status: 400 Bad Request +API error_code: INJECTED +API message: Fault injected by test. + +Updating deployment state... + +Exit code: 1 + +>>> [CLI] bundle summary +Name: publish-failure-retry +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/publish-failure-retry/default +Resources: + Dashboards: + dashboard1: + Name: my dashboard + URL: [DATABRICKS_URL]/dashboardsv3/[DASHBOARD_ID]/published?o=[NUMID] + +>>> [CLI] bundle plan +update dashboards.dashboard1 + +Plan: 0 to add, 1 to change, 0 to delete, 0 unchanged + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/publish-failure-retry/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! +{ + "method": "PATCH", + "path": "/api/2.0/lakeview/dashboards/[DASHBOARD_ID]", + "body": { + "display_name": "my dashboard", + "parent_path": "/Workspace/Users/[USERNAME]/.bundle/publish-failure-retry/default/resources", + "serialized_dashboard": "{\"pages\":[{\"name\":\"test-page\",\"displayName\":\"Test Dashboard\"}]}\n", + "warehouse_id": "someid" + } +} +{ + "method": "POST", + "path": "/api/2.0/lakeview/dashboards/[DASHBOARD_ID]/published", + "body": { + "embed_credentials": false, + "warehouse_id": "someid" + } +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.dashboards.dashboard1 + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/publish-failure-retry/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/script b/acceptance/bundle/resources/dashboards/publish-failure-retry/script new file mode 100644 index 00000000000..3b430628865 --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/script @@ -0,0 +1,30 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +# Inject a single publish failure so the first deploy creates the dashboard +# draft but fails to publish it. +fault.py "POST /api/2.0/lakeview/dashboards/*" 400 0 1 + +# First deploy: dashboard is created and saved to state, but publish fails. +errcode trace $CLI bundle deploy + +# Dashboard should be in state (tracked) despite the publish failure. +trace $CLI bundle summary + +# Plan should show that publishing is still needed. +trace $CLI bundle plan + +# Discard first-deploy requests so the output only contains second-deploy +# calls, making it easy to confirm no CREATE was issued. +rm out.requests.txt + +# Second deploy: fault is gone; must publish the existing draft, not create a new one. +trace $CLI bundle deploy + +# Confirm: second deploy issued an UPDATE and a PUBLISH call but no CREATE. +print_requests.py //lakeview/dashboards diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml b/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml new file mode 100644 index 00000000000..ac1be44882d --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml @@ -0,0 +1,12 @@ +Cloud = false +Local = true +RecordRequests = true + +# Only run with the direct engine: the test verifies direct engine's SaveState +# behavior (draft persists on publish failure and is re-published on retry). +[EnvMatrix] +DATABRICKS_BUNDLE_ENGINE = ["direct"] + +[[Repls]] +Old = "[0-9a-f]{32}" +New = "[DASHBOARD_ID]" diff --git a/bundle/direct/dresources/dashboard.go b/bundle/direct/dresources/dashboard.go index 06b764e0da3..9d15ac35320 100644 --- a/bundle/direct/dresources/dashboard.go +++ b/bundle/direct/dresources/dashboard.go @@ -11,7 +11,6 @@ import ( "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/bundle/deployplan" - "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/structs/structpath" "github.com/databricks/cli/libs/utils" "github.com/databricks/databricks-sdk-go" @@ -340,6 +339,9 @@ func (r *ResourceDashboard) DoCreate(ctx context.Context, engine *Engine, config // Persist the etag in state. config.Etag = createResp.Etag + // Save state before publishing so an interrupted publish leaves a tracked + // draft rather than an orphan. The next deploy finds the draft in state and + // re-publishes via DoUpdate without recreating the dashboard. engine.SaveState(ctx, createResp.DashboardId, config) var publishResp *dashboards.PublishedDashboard @@ -347,16 +349,7 @@ func (r *ResourceDashboard) DoCreate(ctx context.Context, engine *Engine, config if config.Published { publishResp, err = r.publishDashboard(ctx, createResp.DashboardId, config) if err != nil { - // If the publish fails, we should delete the dashboard to avoid leaving it in a bad state. - deleteErr := r.client.Lakeview.Trash(ctx, dashboards.TrashDashboardRequest{ - DashboardId: createResp.DashboardId, - }) - if deleteErr != nil { - log.Warnf(ctx, "failed to delete draft dashboard %s after publish failed: %v", createResp.DashboardId, deleteErr) - return "", nil, deleteErr - } return "", nil, err - // QQQ: instead, we could store partial state with published=false } } From 8a934e9d6c0c6dec1dc75f737432cf7b1b9afcac Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 2 Jun 2026 22:04:04 +0200 Subject: [PATCH 08/32] acceptance/dashboards: normalize ?o=/?w= workspace param in URL replacements The local testserver uses ?o= and cloud uses ?w= for the workspace/org ID in dashboard published URLs. Add a parent-level [[Repls]] rule that maps both to ?[WSPARAM]= so the output files are environment-independent. Co-authored-by: Denis Bilenko --- .../publish-failure-cleans-up-dashboard/out.summary.direct.txt | 2 +- .../resources/dashboards/publish-failure-retry/output.txt | 2 +- acceptance/bundle/resources/dashboards/test.toml | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.summary.direct.txt b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.summary.direct.txt index 52c168b5302..39ec72db595 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.summary.direct.txt +++ b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.summary.direct.txt @@ -9,4 +9,4 @@ Resources: Dashboards: dashboard1: Name: my dashboard - URL: [DATABRICKS_URL]/dashboardsv3/[DASHBOARD_ID]/published?o=[NUMID] + URL: [DATABRICKS_URL]/dashboardsv3/[DASHBOARD_ID]/published?[WSPARAM]=[NUMID] diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt b/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt index 05fd57f6c99..f5ab0131d7d 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt @@ -23,7 +23,7 @@ Resources: Dashboards: dashboard1: Name: my dashboard - URL: [DATABRICKS_URL]/dashboardsv3/[DASHBOARD_ID]/published?o=[NUMID] + URL: [DATABRICKS_URL]/dashboardsv3/[DASHBOARD_ID]/published?[WSPARAM]=[NUMID] >>> [CLI] bundle plan update dashboards.dashboard1 diff --git a/acceptance/bundle/resources/dashboards/test.toml b/acceptance/bundle/resources/dashboards/test.toml index f42de243fbf..808283815bf 100644 --- a/acceptance/bundle/resources/dashboards/test.toml +++ b/acceptance/bundle/resources/dashboards/test.toml @@ -22,3 +22,6 @@ INJECT_STALE_ON_DIRECT = "1" # which flaked these read-after-deploy checks. 20 attempts x 1000ms gives a ~20s window. RETRY_MAX_ATTEMPTS = "20" RETRY_INTERVAL_MS = "1000" +[[Repls]] +Old = "\\?(o|w)=" +New = "?[WSPARAM]=" From 01b577ead16ed1533855be1dd2d87561725f49d3 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 2 Jun 2026 22:20:39 +0200 Subject: [PATCH 09/32] acceptance/dashboards: fix ?o=/?w= workspace param normalization Use per-test [[Repls]] rules (matching raw digits) in the two publish-failure tests so the URL parameter is normalized to ?[WSPARAM]=[NUMID] regardless of whether the testserver or cloud environment is used. Revert the over-broad parent-level rule that broke detect-change's existing ?[ow]=... cleanup. Co-authored-by: Denis Bilenko --- .../dashboards/publish-failure-cleans-up-dashboard/test.toml | 5 +++++ .../resources/dashboards/publish-failure-retry/test.toml | 5 +++++ acceptance/bundle/resources/dashboards/test.toml | 3 --- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/test.toml b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/test.toml index a8ede7b099c..ed07dda2980 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/test.toml +++ b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/test.toml @@ -10,3 +10,8 @@ Response.Body = '{"error_code": "RESOURCE_DOES_NOT_EXIST", "message": "Warehouse [[Repls]] Old = "[0-9a-f]{32}" New = "[DASHBOARD_ID]" + +# Dashboard published URLs use ?o= (local testserver) or ?w= (cloud) for the workspace/org ID. +[[Repls]] +Old = '\?[ow]=\d+' +New = "?[WSPARAM]=[NUMID]" diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml b/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml index ac1be44882d..3d4dd86612e 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml @@ -10,3 +10,8 @@ DATABRICKS_BUNDLE_ENGINE = ["direct"] [[Repls]] Old = "[0-9a-f]{32}" New = "[DASHBOARD_ID]" + +# Dashboard published URLs use ?o= (local testserver) or ?w= (cloud) for the workspace/org ID. +[[Repls]] +Old = '\?[ow]=\d+' +New = "?[WSPARAM]=[NUMID]" diff --git a/acceptance/bundle/resources/dashboards/test.toml b/acceptance/bundle/resources/dashboards/test.toml index 808283815bf..f42de243fbf 100644 --- a/acceptance/bundle/resources/dashboards/test.toml +++ b/acceptance/bundle/resources/dashboards/test.toml @@ -22,6 +22,3 @@ INJECT_STALE_ON_DIRECT = "1" # which flaked these read-after-deploy checks. 20 attempts x 1000ms gives a ~20s window. RETRY_MAX_ATTEMPTS = "20" RETRY_INTERVAL_MS = "1000" -[[Repls]] -Old = "\\?(o|w)=" -New = "?[WSPARAM]=" From 087e744eb2c3d045a3a603998fccc51752f0d324 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 2 Jun 2026 22:41:17 +0200 Subject: [PATCH 10/32] acceptance/dashboards: fix &>> redirect for bash 3.2 compatibility macOS ships bash 3.2 which does not support &>> (bash 4+). Replace with >> file 2>&1 which works on all bash versions. Co-authored-by: Denis Bilenko --- .../dashboards/publish-failure-cleans-up-dashboard/script | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/script b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/script index e8062b99e6e..e28caa3d0de 100755 --- a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/script +++ b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/script @@ -5,7 +5,7 @@ envsubst < databricks.yml.tmpl > databricks.yml errcode trace $CLI bundle deploy &> out.deploy.$DATABRICKS_BUNDLE_ENGINE.txt # After publish failure the dashboard draft should be in state (direct) or cleaned up (terraform). -trace $CLI bundle summary &>> out.summary.$DATABRICKS_BUNDLE_ENGINE.txt +trace $CLI bundle summary >> out.summary.$DATABRICKS_BUNDLE_ENGINE.txt 2>&1 # API request sequence differs between engines (direct: no DELETE; terraform: DELETE to clean up). unset MSYS_NO_PATHCONV From 3f192391dc07cd1acdc9045c6757ac77ef867ca7 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 3 Jun 2026 10:51:38 +0200 Subject: [PATCH 11/32] acceptance/dashboards: unset MSYS_NO_PATHCONV before fault.py on Windows MSYS_NO_PATHCONV=1 (set in the parent test.toml) prevents MSYS2 from converting POSIX paths to Windows paths, causing Python to receive a broken path (/c/a/... instead of C:\a\...) when invoking fault.py. Unset it before the fault.py call, matching the pattern used by other dashboard scripts before their bin helper invocations. Co-authored-by: Denis Bilenko --- .../bundle/resources/dashboards/publish-failure-retry/script | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/script b/acceptance/bundle/resources/dashboards/publish-failure-retry/script index 3b430628865..2096b92ee67 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-retry/script +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/script @@ -6,6 +6,10 @@ cleanup() { } trap cleanup EXIT +# unset MSYS_NO_PATHCONV so MSYS2 converts the script path to a Windows path +# when invoking the Python interpreter (required for fault.py to be found on Windows). +unset MSYS_NO_PATHCONV + # Inject a single publish failure so the first deploy creates the dashboard # draft but fails to publish it. fault.py "POST /api/2.0/lakeview/dashboards/*" 400 0 1 From b8f56a4a1732baf13a57c06f21276eec1a13c4ea Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 3 Jun 2026 13:33:39 +0200 Subject: [PATCH 12/32] direct: Engine dedup+logging; dashboard saves draft state as Published=false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine.SaveState: - Accepts resourceKey for logging: "SaveState: resources.X id=Y N bytes: {...}" - Skips WAL write if state is unchanged (structdiff.IsEqual), logging a skip message. - Records the last saved value in e.lastSaved for subsequent comparisons. Dashboard DoCreate: - Saves intermediate state with Published=false (the actual draft state) instead of the user's Published=true. This ensures the planner sees a real diff (false→true) on the next deploy if publish is interrupted, rather than treating the resource as up-to-date and silently skipping the publish. publish-failure-retry acceptance test: - Adds `bundle plan -o json` and extracts the published change to verify old=false, new=true in the plan after a failed publish. README.md: update stale SetID+SaveState reference to current SaveState(ctx, id, state) API. Co-authored-by: Denis Bilenko --- .../out.plan_published_change.json | 6 ++++ .../publish-failure-retry/output.txt | 11 +++++-- .../dashboards/publish-failure-retry/script | 6 ++-- bundle/direct/apply.go | 4 +-- bundle/direct/dresources/README.md | 2 +- bundle/direct/dresources/dashboard.go | 10 +++++-- bundle/direct/dresources/engine.go | 30 +++++++++++++++---- 7 files changed, 52 insertions(+), 17 deletions(-) create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-retry/out.plan_published_change.json diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/out.plan_published_change.json b/acceptance/bundle/resources/dashboards/publish-failure-retry/out.plan_published_change.json new file mode 100644 index 00000000000..0a05a1b42d3 --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/out.plan_published_change.json @@ -0,0 +1,6 @@ +{ + "action": "update", + "old": false, + "new": true, + "remote": false +} diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt b/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt index f5ab0131d7d..b1f7b6bb3b3 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt @@ -25,10 +25,15 @@ Resources: Name: my dashboard URL: [DATABRICKS_URL]/dashboardsv3/[DASHBOARD_ID]/published?[WSPARAM]=[NUMID] ->>> [CLI] bundle plan -update dashboards.dashboard1 +>>> [CLI] bundle plan -o json -Plan: 0 to add, 1 to change, 0 to delete, 0 unchanged +>>> cat out.plan_published_change.json +{ + "action": "update", + "old": false, + "new": true, + "remote": false +} >>> [CLI] bundle deploy Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/publish-failure-retry/default/files... diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/script b/acceptance/bundle/resources/dashboards/publish-failure-retry/script index 2096b92ee67..c231427120b 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-retry/script +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/script @@ -20,8 +20,10 @@ errcode trace $CLI bundle deploy # Dashboard should be in state (tracked) despite the publish failure. trace $CLI bundle summary -# Plan should show that publishing is still needed. -trace $CLI bundle plan +# Plan should show published: false (saved state) -> true (desired). +# Capture the Changes entry for 'published' to verify the diff direction. +trace $CLI bundle plan -o json | jq '.plan["resources.dashboards.dashboard1"].changes.published' > out.plan_published_change.json +trace cat out.plan_published_change.json # Discard first-deploy requests so the output only contains second-deploy # calls, making it easy to confirm no CREATE was issued. diff --git a/bundle/direct/apply.go b/bundle/direct/apply.go index 3e4711df82a..19cf442784d 100644 --- a/bundle/direct/apply.go +++ b/bundle/direct/apply.go @@ -51,7 +51,7 @@ func (d *DeploymentUnit) Deploy(ctx context.Context, db *dstate.DeploymentState, } func (d *DeploymentUnit) Create(ctx context.Context, db *dstate.DeploymentState, newState any) error { - engine := dresources.NewEngine(d.Adapter.StateType(), func(id string, x any) error { + engine := dresources.NewEngine(d.ResourceKey, d.Adapter.StateType(), func(id string, x any) error { return db.SaveState(d.ResourceKey, id, x, d.DependsOn) }) @@ -129,7 +129,7 @@ func (d *DeploymentUnit) Update(ctx context.Context, db *dstate.DeploymentState, return fmt.Errorf("internal error: DoUpdate not implemented for resource %s", d.ResourceKey) } - engine := dresources.NewEngine(d.Adapter.StateType(), func(_ string, x any) error { + engine := dresources.NewEngine(d.ResourceKey, d.Adapter.StateType(), func(_ string, x any) error { return db.SaveState(d.ResourceKey, id, x, d.DependsOn) }) remoteState, err := retryOnTransient(ctx, func() (any, error) { diff --git a/bundle/direct/dresources/README.md b/bundle/direct/dresources/README.md index 1c4c37ff957..37056d7bd23 100644 --- a/bundle/direct/dresources/README.md +++ b/bundle/direct/dresources/README.md @@ -36,7 +36,7 @@ If a resource has fields that must not be sent in updates (deploy-only, lifecycl ## Async APIs -For resources whose create or update is asynchronous, poll inline inside `DoCreate`/`DoUpdate` after the initial API call. To prevent orphaning if deployment is interrupted during a long wait, call `engine.SetID(id)` then `engine.SaveState(config)` immediately after the resource is created and before any waiting. The framework provides a `*Engine` as the second argument to both methods. +For resources whose create or update is asynchronous, poll inline inside `DoCreate`/`DoUpdate` after the initial API call. To prevent orphaning if deployment is interrupted during a long wait, call `engine.SaveState(ctx, id, config)` immediately after the resource is created and before any waiting. The framework provides a `*Engine` as the second argument to both methods. ## Slice ordering: KeyedSlices diff --git a/bundle/direct/dresources/dashboard.go b/bundle/direct/dresources/dashboard.go index 9d15ac35320..ecaf20cef99 100644 --- a/bundle/direct/dresources/dashboard.go +++ b/bundle/direct/dresources/dashboard.go @@ -339,10 +339,14 @@ func (r *ResourceDashboard) DoCreate(ctx context.Context, engine *Engine, config // Persist the etag in state. config.Etag = createResp.Etag - // Save state before publishing so an interrupted publish leaves a tracked - // draft rather than an orphan. The next deploy finds the draft in state and - // re-publishes via DoUpdate without recreating the dashboard. + // Save state with Published=false: the dashboard exists as a draft; publish + // has not succeeded yet. Using Published=false ensures the planner sees a + // real diff (false→true) if publish is interrupted, triggering a DoUpdate + // on the next deploy instead of silently treating the resource as up-to-date. + savedPublished := config.Published + config.Published = false engine.SaveState(ctx, createResp.DashboardId, config) + config.Published = savedPublished var publishResp *dashboards.PublishedDashboard // Note, today config.Published is always true (we do not have this field in input config). diff --git a/bundle/direct/dresources/engine.go b/bundle/direct/dresources/engine.go index 46deca6600d..0172f287ff1 100644 --- a/bundle/direct/dresources/engine.go +++ b/bundle/direct/dresources/engine.go @@ -2,35 +2,41 @@ package dresources import ( "context" + "encoding/json" "fmt" "reflect" + "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/logdiag" + "github.com/databricks/cli/libs/structs/structdiff" ) // Engine provides state persistence to resource implementations. // Pass it to DoCreate or DoUpdate to save intermediate state before long-running // wait operations, so the resource is not orphaned if deployment is interrupted. type Engine struct { - id string - stateType reflect.Type - saveFunc func(id string, x any) error + resourceKey string + id string + stateType reflect.Type + saveFunc func(id string, x any) error + lastSaved any } // NewEngine creates an Engine with the given state type and save function. // The framework calls this before invoking DoCreate or DoUpdate. -func NewEngine(stateType reflect.Type, saveFunc func(id string, x any) error) *Engine { - return &Engine{id: "", stateType: stateType, saveFunc: saveFunc} +func NewEngine(resourceKey string, stateType reflect.Type, saveFunc func(id string, x any) error) *Engine { + return &Engine{resourceKey: resourceKey, id: "", stateType: stateType, saveFunc: saveFunc, lastSaved: nil} } // NewNopEngine creates an Engine that discards all saves. Use in tests. func NewNopEngine(stateType reflect.Type) *Engine { - return NewEngine(stateType, func(_ string, _ any) error { return nil }) + return NewEngine("", stateType, func(_ string, _ any) error { return nil }) } // SaveState saves the resource state. id must be the resource's identifier; on // the first call it is recorded, and subsequent calls panic if a different id is // passed. x must be a pointer to the same struct type as the resource's state. +// If the state is identical to what was last saved, the write is skipped. // Failures to persist state are logged but do not abort the deployment — the // resource already exists and aborting would not undo its creation. func (e *Engine) SaveState(ctx context.Context, id string, x any) { @@ -43,7 +49,19 @@ func (e *Engine) SaveState(ctx context.Context, id string, x any) { if xt != e.stateType { panic(fmt.Sprintf("SaveState: type mismatch: expected %v, got %v", e.stateType, xt)) } + if e.lastSaved != nil && structdiff.IsEqual(e.lastSaved, x) { + log.Debugf(ctx, "SaveState: %s id=%s: skipping, state unchanged", e.resourceKey, id) + return + } + b, _ := json.Marshal(x) + preview := string(b) + if len(preview) > 100 { + preview = preview[:100] + } + log.Debugf(ctx, "SaveState: %s id=%s %d bytes: %s", e.resourceKey, id, len(b), preview) if err := e.saveFunc(e.id, x); err != nil { logdiag.LogError(ctx, err) + return } + e.lastSaved = x } From d135237cc9d29e550df9df18521e1f7629d9f2bf Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 3 Jun 2026 17:35:03 +0200 Subject: [PATCH 13/32] direct: inline WaitAfterCreate/WaitAfterUpdate into sql_warehouse DoCreate/DoUpdate Mergiraf reintroduced the standalone WaitAfterCreate and WaitAfterUpdate methods during the rebase against main (sql_warehouse.go had a merge conflict). Inline their wait logic directly into DoCreate and DoUpdate and remove the standalone methods, consistent with the Engine callback pattern used by other resources. Co-authored-by: Denis Bilenko --- bundle/direct/dresources/sql_warehouse.go | 74 ++++++++++------------- 1 file changed, 32 insertions(+), 42 deletions(-) diff --git a/bundle/direct/dresources/sql_warehouse.go b/bundle/direct/dresources/sql_warehouse.go index ecc4d66953d..206516a36c2 100644 --- a/bundle/direct/dresources/sql_warehouse.go +++ b/bundle/direct/dresources/sql_warehouse.go @@ -123,7 +123,31 @@ func (r *ResourceSqlWarehouse) DoCreate(ctx context.Context, _ *Engine, config * if err != nil { return "", nil, err } - return waiter.Id, nil, nil + id := waiter.Id + + if config.Lifecycle == nil || config.Lifecycle.Started == nil { + return id, nil, nil + } + + // Always wait for RUNNING first: warehouses start asynchronously. + _, err = r.client.Warehouses.WaitGetWarehouseRunning(ctx, id, 20*time.Minute, nil) + if err != nil { + return "", nil, err + } + + if !*config.Lifecycle.Started { + // started=false: stop the warehouse after it reaches RUNNING. + stopWaiter, err := r.client.Warehouses.Stop(ctx, sql.StopRequest{Id: id}) + if err != nil { + return "", nil, err + } + _, err = stopWaiter.Get() + if err != nil { + return "", nil, err + } + } + + return id, nil, nil } // hasWarehouseChanges reports whether the plan entry contains any Update changes @@ -177,59 +201,25 @@ func (r *ResourceSqlWarehouse) DoUpdate(ctx context.Context, _ *Engine, id strin alreadyRunning = true } if desiredStarted && !alreadyRunning { - // lifecycle.started=true: fire Start; WaitAfterUpdate polls for RUNNING. _, err := r.client.Warehouses.Start(ctx, sql.StartRequest{Id: id}) - return nil, err + if err != nil { + return nil, err + } } else if !desiredStarted && alreadyRunning { - // lifecycle.started=false: fire Stop; WaitAfterUpdate polls for STOPPED. _, err := r.client.Warehouses.Stop(ctx, sql.StopRequest{Id: id}) - return nil, err - } - - return nil, nil -} - -// WaitAfterUpdate waits for the warehouse to reach the desired lifecycle state after DoUpdate. -func (r *ResourceSqlWarehouse) WaitAfterUpdate(ctx context.Context, id string, config *SqlWarehouseState) (*SqlWarehouseRemote, error) { - if config.Lifecycle == nil || config.Lifecycle.Started == nil { - return nil, nil + if err != nil { + return nil, err + } } - if *config.Lifecycle.Started { + if desiredStarted { _, err := r.client.Warehouses.WaitGetWarehouseRunning(ctx, id, 20*time.Minute, nil) return nil, err } - _, err := r.client.Warehouses.WaitGetWarehouseStopped(ctx, id, 20*time.Minute, nil) return nil, err } -// WaitAfterCreate waits for the warehouse to be ready, then stops it if lifecycle.started=false. -// Warehouses are created in a starting state; WaitGetWarehouseRunning waits for them to be RUNNING. -func (r *ResourceSqlWarehouse) WaitAfterCreate(ctx context.Context, id string, config *SqlWarehouseState) (*SqlWarehouseRemote, error) { - if config.Lifecycle == nil || config.Lifecycle.Started == nil { - return nil, nil - } - - // Always wait for RUNNING first: warehouses start asynchronously. - _, err := r.client.Warehouses.WaitGetWarehouseRunning(ctx, id, 20*time.Minute, nil) - if err != nil { - return nil, err - } - - if !*config.Lifecycle.Started { - // started=false: stop the warehouse after it reaches RUNNING. - stopWaiter, err := r.client.Warehouses.Stop(ctx, sql.StopRequest{Id: id}) - if err != nil { - return nil, err - } - _, err = stopWaiter.Get() - return nil, err - } - - return nil, nil -} - func (r *ResourceSqlWarehouse) DoDelete(ctx context.Context, oldID string, _ *SqlWarehouseState) error { return r.client.Warehouses.DeleteById(ctx, oldID) } From e60f663ff22f396117a7f18e3fdf499e59cb54da Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 4 Jun 2026 18:50:06 +0200 Subject: [PATCH 14/32] acceptance/dashboards: use replace_ids.py and inline plan output in publish-failure-retry - Replace fixed hex regex [[Repls]] with replace_ids.py called after the first deploy; the real dashboard ID is captured from state as [DASHBOARD1_ID]. - Inline the plan -o json | jq output directly into output.txt instead of saving to out.plan_published_change.json and trace cat-ing it. Co-authored-by: Denis Bilenko --- .../out.plan_published_change.json | 6 ------ .../dashboards/publish-failure-retry/output.txt | 10 ++++------ .../resources/dashboards/publish-failure-retry/script | 7 ++++--- .../dashboards/publish-failure-retry/test.toml | 4 ---- 4 files changed, 8 insertions(+), 19 deletions(-) delete mode 100644 acceptance/bundle/resources/dashboards/publish-failure-retry/out.plan_published_change.json diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/out.plan_published_change.json b/acceptance/bundle/resources/dashboards/publish-failure-retry/out.plan_published_change.json deleted file mode 100644 index 0a05a1b42d3..00000000000 --- a/acceptance/bundle/resources/dashboards/publish-failure-retry/out.plan_published_change.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "action": "update", - "old": false, - "new": true, - "remote": false -} diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt b/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt index b1f7b6bb3b3..aa00db78f6a 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/output.txt @@ -4,7 +4,7 @@ Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/publish-failure-re Deploying resources... Error: cannot create resources.dashboards.dashboard1: Fault injected by test. (400 INJECTED) -Endpoint: POST [DATABRICKS_URL]/api/2.0/lakeview/dashboards/[DASHBOARD_ID]/published +Endpoint: POST [DATABRICKS_URL]/api/2.0/lakeview/dashboards/[DASHBOARD1_ID]/published HTTP Status: 400 Bad Request API error_code: INJECTED API message: Fault injected by test. @@ -23,11 +23,9 @@ Resources: Dashboards: dashboard1: Name: my dashboard - URL: [DATABRICKS_URL]/dashboardsv3/[DASHBOARD_ID]/published?[WSPARAM]=[NUMID] + URL: [DATABRICKS_URL]/dashboardsv3/[DASHBOARD1_ID]/published?[WSPARAM]=[NUMID] >>> [CLI] bundle plan -o json - ->>> cat out.plan_published_change.json { "action": "update", "old": false, @@ -42,7 +40,7 @@ Updating deployment state... Deployment complete! { "method": "PATCH", - "path": "/api/2.0/lakeview/dashboards/[DASHBOARD_ID]", + "path": "/api/2.0/lakeview/dashboards/[DASHBOARD1_ID]", "body": { "display_name": "my dashboard", "parent_path": "/Workspace/Users/[USERNAME]/.bundle/publish-failure-retry/default/resources", @@ -52,7 +50,7 @@ Deployment complete! } { "method": "POST", - "path": "/api/2.0/lakeview/dashboards/[DASHBOARD_ID]/published", + "path": "/api/2.0/lakeview/dashboards/[DASHBOARD1_ID]/published", "body": { "embed_credentials": false, "warehouse_id": "someid" diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/script b/acceptance/bundle/resources/dashboards/publish-failure-retry/script index c231427120b..e776d2af933 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-retry/script +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/script @@ -17,13 +17,14 @@ fault.py "POST /api/2.0/lakeview/dashboards/*" 400 0 1 # First deploy: dashboard is created and saved to state, but publish fails. errcode trace $CLI bundle deploy +# Capture the dashboard ID from state so subsequent output is normalized. +replace_ids.py + # Dashboard should be in state (tracked) despite the publish failure. trace $CLI bundle summary # Plan should show published: false (saved state) -> true (desired). -# Capture the Changes entry for 'published' to verify the diff direction. -trace $CLI bundle plan -o json | jq '.plan["resources.dashboards.dashboard1"].changes.published' > out.plan_published_change.json -trace cat out.plan_published_change.json +trace $CLI bundle plan -o json | jq '.plan["resources.dashboards.dashboard1"].changes.published' # Discard first-deploy requests so the output only contains second-deploy # calls, making it easy to confirm no CREATE was issued. diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml b/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml index 3d4dd86612e..177fe084644 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml @@ -7,10 +7,6 @@ RecordRequests = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] -[[Repls]] -Old = "[0-9a-f]{32}" -New = "[DASHBOARD_ID]" - # Dashboard published URLs use ?o= (local testserver) or ?w= (cloud) for the workspace/org ID. [[Repls]] Old = '\?[ow]=\d+' From 37ba9dea293fe9624325d477ffbf2df3b39af55f Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 5 Jun 2026 15:03:08 +0200 Subject: [PATCH 15/32] direct: save state before wait/publish in sql_warehouse DoCreate and dashboard DoUpdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more cases where state could be persisted earlier to avoid orphaning or stale-etag conflicts: sql_warehouse DoCreate: the warehouse is created, then DoCreate polls for RUNNING (and may Stop it). If interrupted during the wait, the warehouse was orphaned. Now SaveState is called right after Create returns the id, before the wait. dashboard DoUpdate: Update() bumps the server-side etag, then publishDashboard() can fail. Mirror the DoCreate fix — save the new etag with Published=false before publishing. This keeps the etag in sync (a stale etag makes the next Update fail with a conflict) and records published=false so the planner re-publishes next deploy. Resolves the pre-existing TODO. Add publish-failure-retry-on-update acceptance test: deploy, then trigger an update with an injected publish failure, and verify the update issued a PATCH (no CREATE) and the plan records published old=false. Co-authored-by: Denis Bilenko --- .../dashboard.lvdash.json | 1 + .../databricks.yml.tmpl | 9 +++ .../out.test.toml | 4 ++ .../output.txt | 56 +++++++++++++++++++ .../publish-failure-retry-on-update/script | 33 +++++++++++ .../publish-failure-retry-on-update/test.toml | 13 +++++ bundle/direct/dresources/dashboard.go | 12 +++- bundle/direct/dresources/sql_warehouse.go | 6 +- 8 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/dashboard.lvdash.json create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/out.test.toml create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/output.txt create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/script create mode 100644 acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/test.toml diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/dashboard.lvdash.json b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/dashboard.lvdash.json new file mode 100644 index 00000000000..0bfc5797ff0 --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/dashboard.lvdash.json @@ -0,0 +1 @@ +{"pages":[{"name":"test-page","displayName":"Test Dashboard"}]} diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/databricks.yml.tmpl b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/databricks.yml.tmpl new file mode 100644 index 00000000000..de7419dcbdb --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/databricks.yml.tmpl @@ -0,0 +1,9 @@ +bundle: + name: publish-failure-retry-on-update + +resources: + dashboards: + dashboard1: + display_name: my dashboard + warehouse_id: someid + file_path: ./dashboard.lvdash.json diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/out.test.toml b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/out.test.toml new file mode 100644 index 00000000000..a29f11b9ab2 --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/out.test.toml @@ -0,0 +1,4 @@ +Local = true +Cloud = false +RequiresWarehouse = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/output.txt b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/output.txt new file mode 100644 index 00000000000..8017faf3041 --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/output.txt @@ -0,0 +1,56 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/publish-failure-retry-on-update/default/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/publish-failure-retry-on-update/default/files... +Deploying resources... +Error: cannot update resources.dashboards.dashboard1: updating id=[DASHBOARD1_ID]: Fault injected by test. (400 INJECTED) + +Endpoint: POST [DATABRICKS_URL]/api/2.0/lakeview/dashboards/[DASHBOARD1_ID]/published +HTTP Status: 400 Bad Request +API error_code: INJECTED +API message: Fault injected by test. + +Updating deployment state... + +Exit code: 1 +{ + "method": "PATCH", + "path": "/api/2.0/lakeview/dashboards/[DASHBOARD1_ID]", + "body": { + "display_name": "my dashboard renamed", + "parent_path": "/Workspace/Users/[USERNAME]/.bundle/publish-failure-retry-on-update/default/resources", + "serialized_dashboard": "{\"pages\":[{\"name\":\"test-page\",\"displayName\":\"Test Dashboard\"}]}\n", + "warehouse_id": "someid" + } +} +{ + "method": "POST", + "path": "/api/2.0/lakeview/dashboards/[DASHBOARD1_ID]/published", + "body": { + "embed_credentials": false, + "warehouse_id": "someid" + } +} + +>>> [CLI] bundle plan -o json +{ + "action": "skip", + "reason": "remote_already_set", + "old": false, + "new": true, + "remote": true +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.dashboards.dashboard1 + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/publish-failure-retry-on-update/default + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/script b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/script new file mode 100644 index 00000000000..27471ef05f6 --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/script @@ -0,0 +1,33 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +# unset MSYS_NO_PATHCONV so MSYS2 converts the script path to a Windows path +# when invoking the Python interpreter (required for fault.py to be found on Windows). +unset MSYS_NO_PATHCONV + +# First deploy succeeds fully: the dashboard is created and published. +trace $CLI bundle deploy +replace_ids.py +rm out.requests.txt + +# Inject a single publish failure for the update below. +fault.py "POST /api/2.0/lakeview/dashboards/*" 400 0 1 + +# Change the dashboard to trigger an Update, which bumps the server-side etag. +update_file.py databricks.yml "my dashboard" "my dashboard renamed" + +# Deploy: Update (PATCH) succeeds and bumps the etag, but the publish (POST) fails. +errcode trace $CLI bundle deploy + +# The failed deploy issued a PATCH (update) and a POST (publish), but no CREATE +# (POST /api/2.0/lakeview/dashboards): the existing dashboard was updated in place. +print_requests.py //lakeview/dashboards + +# Plan shows published old=false: state was saved with published=false (and the bumped +# etag) before the failed publish, so the next deploy knows publishing is still pending. +trace $CLI bundle plan -o json | jq '.plan["resources.dashboards.dashboard1"].changes.published' diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/test.toml b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/test.toml new file mode 100644 index 00000000000..5272a2289ad --- /dev/null +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/test.toml @@ -0,0 +1,13 @@ +Cloud = false +Local = true +RecordRequests = true + +# Only run with the direct engine: the test verifies direct engine's SaveState +# behavior during DoUpdate (new etag + published=false persisted before a failed publish). +[EnvMatrix] +DATABRICKS_BUNDLE_ENGINE = ["direct"] + +# Dashboard published URLs use ?o= (local testserver) or ?w= (cloud) for the workspace/org ID. +[[Repls]] +Old = '\?[ow]=\d+' +New = "?[WSPARAM]=[NUMID]" diff --git a/bundle/direct/dresources/dashboard.go b/bundle/direct/dresources/dashboard.go index ecaf20cef99..f1e2de97cc4 100644 --- a/bundle/direct/dresources/dashboard.go +++ b/bundle/direct/dresources/dashboard.go @@ -360,7 +360,7 @@ func (r *ResourceDashboard) DoCreate(ctx context.Context, engine *Engine, config return createResp.DashboardId, responseToState(createResp, publishResp, dashboard.SerializedDashboard, config.Published), nil } -func (r *ResourceDashboard) DoUpdate(ctx context.Context, _ *Engine, id string, config *DashboardState, _ *PlanEntry) (*DashboardState, error) { +func (r *ResourceDashboard) DoUpdate(ctx context.Context, engine *Engine, id string, config *DashboardState, _ *PlanEntry) (*DashboardState, error) { dashboard, err := prepareDashboardRequest(config) if err != nil { return nil, err @@ -380,15 +380,21 @@ func (r *ResourceDashboard) DoUpdate(ctx context.Context, _ *Engine, id string, return nil, err } - // Persist the etag in state. + // Persist the new etag with Published=false before publishing. Update() bumps the + // etag on the server; if a subsequent publish fails, saving here keeps the etag in + // sync (a stale etag would make the next Update fail with a conflict) and records + // published=false so the planner re-publishes on the next deploy. config.Etag = updateResp.Etag + savedPublished := config.Published + config.Published = false + engine.SaveState(ctx, id, config) + config.Published = savedPublished var publishResp *dashboards.PublishedDashboard // Note, today config.Published is always true (we do not have this field in input config). if config.Published { publishResp, err = r.publishDashboard(ctx, id, config) if err != nil { - // TODO: store partial state with published=false? return nil, err } } diff --git a/bundle/direct/dresources/sql_warehouse.go b/bundle/direct/dresources/sql_warehouse.go index 206516a36c2..ba584c84126 100644 --- a/bundle/direct/dresources/sql_warehouse.go +++ b/bundle/direct/dresources/sql_warehouse.go @@ -118,13 +118,17 @@ func (r *ResourceSqlWarehouse) DoRead(ctx context.Context, id string) (*SqlWareh } // DoCreate creates the warehouse and returns its id. -func (r *ResourceSqlWarehouse) DoCreate(ctx context.Context, _ *Engine, config *SqlWarehouseState) (string, *SqlWarehouseRemote, error) { +func (r *ResourceSqlWarehouse) DoCreate(ctx context.Context, engine *Engine, config *SqlWarehouseState) (string, *SqlWarehouseRemote, error) { waiter, err := r.client.Warehouses.Create(ctx, config.CreateWarehouseRequest) if err != nil { return "", nil, err } id := waiter.Id + // Save state immediately after the warehouse is created so it is not orphaned + // if the subsequent wait or stop is interrupted. + engine.SaveState(ctx, id, config) + if config.Lifecycle == nil || config.Lifecycle.Started == nil { return id, nil, nil } From 106e36b3f5f9c728b9b4f8370141975a91b38853 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 5 Jun 2026 17:07:36 +0200 Subject: [PATCH 16/32] direct: save state before wait in model_serving_endpoint DoUpdate DoUpdate applies up to four sequential mutating calls (tags, AI gateway, config, notifications) and then waits up to 35 minutes for the endpoint to finish updating. If that wait was interrupted, none of the applied changes were persisted to state, forcing them to be re-applied on the next deploy. Save the new config right after the mutating calls and before the wait, mirroring DoCreate which already saves before waitForEndpointReady. The id (endpoint name) matches the one DoCreate persists, so there is no id mismatch. Co-authored-by: Denis Bilenko --- bundle/direct/dresources/model_serving_endpoint.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bundle/direct/dresources/model_serving_endpoint.go b/bundle/direct/dresources/model_serving_endpoint.go index 5a5db425d91..bf985f5e491 100644 --- a/bundle/direct/dresources/model_serving_endpoint.go +++ b/bundle/direct/dresources/model_serving_endpoint.go @@ -307,7 +307,7 @@ func (r *ResourceModelServingEndpoint) updateTags(ctx context.Context, id string return nil } -func (r *ResourceModelServingEndpoint) DoUpdate(ctx context.Context, _ *Engine, id string, config *serving.CreateServingEndpoint, entry *PlanEntry) (*ModelServingEndpointRemote, error) { +func (r *ResourceModelServingEndpoint) DoUpdate(ctx context.Context, engine *Engine, id string, config *serving.CreateServingEndpoint, entry *PlanEntry) (*ModelServingEndpointRemote, error) { var err error // Terraform makes these API calls sequentially. We do the same here. @@ -341,6 +341,10 @@ func (r *ResourceModelServingEndpoint) DoUpdate(ctx context.Context, _ *Engine, } } + // All mutating calls have been applied; save the new config before the wait so + // an interrupted wait does not require re-applying them on the next deploy. + engine.SaveState(ctx, id, config) + return r.waitForEndpointReady(ctx, config.Name) } From 3deeb64c396961c79102ef9a72b048f726d8620c Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 5 Jun 2026 17:18:59 +0200 Subject: [PATCH 17/32] direct: revert postgres SaveState (waiter.Name() is the LRO operation, not resource id) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous attempt to save state before the async wait in postgres DoCreate used engine.SaveState(ctx, waiter.Name(), config). waiter.Name() returns the LRO operation name (e.g. .../branches/foo/operations/UUID), not the resource name (…/branches/foo) that DoCreate ultimately returns. Using the operation name as the id creates a dead WAL entry and does not help with orphan recovery. Replace with a TODO comment explaining the two options for a proper fix: 1. Derive the resource name from request inputs (Parent + resource-type + Id). 2. Use waiter.Metadata() if it surfaces the resource name before Wait completes. No panic in production: the framework saves state via db.SaveState (not through the Engine) after DoCreate returns, so the mismatch between operation-name and real-id never hits the Engine's id-mismatch check. The test panic was a test artifact where the same Engine was reused across Create→Update. Co-authored-by: Denis Bilenko --- bundle/direct/dresources/postgres_branch.go | 8 ++++++-- bundle/direct/dresources/postgres_catalog.go | 8 ++++++-- bundle/direct/dresources/postgres_endpoint.go | 8 ++++++-- bundle/direct/dresources/postgres_project.go | 8 ++++++-- bundle/direct/dresources/postgres_synced_table.go | 8 ++++++-- 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/bundle/direct/dresources/postgres_branch.go b/bundle/direct/dresources/postgres_branch.go index fc15ac85529..58d4eddab07 100644 --- a/bundle/direct/dresources/postgres_branch.go +++ b/bundle/direct/dresources/postgres_branch.go @@ -106,7 +106,7 @@ func (r *ResourcePostgresBranch) DoRead(ctx context.Context, id string) (*Postgr return makePostgresBranchRemote(branch), nil } -func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, engine *Engine, config *PostgresBranchState) (string, *PostgresBranchRemote, error) { +func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, _ *Engine, config *PostgresBranchState) (string, *PostgresBranchRemote, error) { waiter, err := r.client.Postgres.CreateBranch(ctx, postgres.CreateBranchRequest{ BranchId: config.BranchId, Parent: config.Parent, @@ -129,7 +129,11 @@ func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, engine *Engine, c if err != nil { return "", nil, err } - engine.SaveState(ctx, waiter.Name(), config) + // TODO: save state before the wait to prevent orphaning on interruption. + // waiter.Name() returns the LRO operation name (e.g. .../operations/UUID), + // not the real resource name. We need the resource name to save a valid state + // entry; options: (1) derive it from input (Parent + resource-type + Id), + // (2) call waiter.Metadata() if it exposes the resource name early. // Wait for the branch to be ready (long-running operation) result, err := waiter.Wait(ctx) diff --git a/bundle/direct/dresources/postgres_catalog.go b/bundle/direct/dresources/postgres_catalog.go index 680a3c10e4c..e47df4da1c5 100644 --- a/bundle/direct/dresources/postgres_catalog.go +++ b/bundle/direct/dresources/postgres_catalog.go @@ -91,7 +91,7 @@ func (r *ResourcePostgresCatalog) DoRead(ctx context.Context, id string) (*Postg return makePostgresCatalogRemote(catalog), nil } -func (r *ResourcePostgresCatalog) DoCreate(ctx context.Context, engine *Engine, config *PostgresCatalogState) (string, *PostgresCatalogRemote, error) { +func (r *ResourcePostgresCatalog) DoCreate(ctx context.Context, _ *Engine, config *PostgresCatalogState) (string, *PostgresCatalogRemote, error) { waiter, err := r.client.Postgres.CreateCatalog(ctx, postgres.CreateCatalogRequest{ CatalogId: config.CatalogId, Catalog: postgres.Catalog{ @@ -110,7 +110,11 @@ func (r *ResourcePostgresCatalog) DoCreate(ctx context.Context, engine *Engine, if err != nil { return "", nil, err } - engine.SaveState(ctx, waiter.Name(), config) + // TODO: save state before the wait to prevent orphaning on interruption. + // waiter.Name() returns the LRO operation name (e.g. .../operations/UUID), + // not the real resource name. We need the resource name to save a valid state + // entry; options: (1) derive it from input (Parent + resource-type + Id), + // (2) call waiter.Metadata() if it exposes the resource name early. result, err := waiter.Wait(ctx) if err != nil { diff --git a/bundle/direct/dresources/postgres_endpoint.go b/bundle/direct/dresources/postgres_endpoint.go index 3ffd3d677e0..497f6766390 100644 --- a/bundle/direct/dresources/postgres_endpoint.go +++ b/bundle/direct/dresources/postgres_endpoint.go @@ -137,7 +137,7 @@ func (r *ResourcePostgresEndpoint) waitForReconciliation(ctx context.Context, na } } -func (r *ResourcePostgresEndpoint) DoCreate(ctx context.Context, engine *Engine, config *PostgresEndpointState) (string, *PostgresEndpointRemote, error) { +func (r *ResourcePostgresEndpoint) DoCreate(ctx context.Context, _ *Engine, config *PostgresEndpointState) (string, *PostgresEndpointRemote, error) { waiter, err := r.client.Postgres.CreateEndpoint(ctx, postgres.CreateEndpointRequest{ EndpointId: config.EndpointId, Parent: config.Parent, @@ -160,7 +160,11 @@ func (r *ResourcePostgresEndpoint) DoCreate(ctx context.Context, engine *Engine, if err != nil { return "", nil, err } - engine.SaveState(ctx, waiter.Name(), config) + // TODO: save state before the wait to prevent orphaning on interruption. + // waiter.Name() returns the LRO operation name (e.g. .../operations/UUID), + // not the real resource name. We need the resource name to save a valid state + // entry; options: (1) derive it from input (Parent + resource-type + Id), + // (2) call waiter.Metadata() if it exposes the resource name early. // Wait for the operation to complete result, err := waiter.Wait(ctx) diff --git a/bundle/direct/dresources/postgres_project.go b/bundle/direct/dresources/postgres_project.go index c7d63158975..f588507a64a 100644 --- a/bundle/direct/dresources/postgres_project.go +++ b/bundle/direct/dresources/postgres_project.go @@ -102,7 +102,7 @@ func (r *ResourcePostgresProject) DoRead(ctx context.Context, id string) (*Postg return makePostgresProjectRemote(project), nil } -func (r *ResourcePostgresProject) DoCreate(ctx context.Context, engine *Engine, config *PostgresProjectState) (string, *PostgresProjectRemote, error) { +func (r *ResourcePostgresProject) DoCreate(ctx context.Context, _ *Engine, config *PostgresProjectState) (string, *PostgresProjectRemote, error) { waiter, err := r.client.Postgres.CreateProject(ctx, postgres.CreateProjectRequest{ ProjectId: config.ProjectId, Project: postgres.Project{ @@ -125,7 +125,11 @@ func (r *ResourcePostgresProject) DoCreate(ctx context.Context, engine *Engine, if err != nil { return "", nil, err } - engine.SaveState(ctx, waiter.Name(), config) + // TODO: save state before the wait to prevent orphaning on interruption. + // waiter.Name() returns the LRO operation name (e.g. .../operations/UUID), + // not the real resource name. We need the resource name to save a valid state + // entry; options: (1) derive it from input (Parent + resource-type + Id), + // (2) call waiter.Metadata() if it exposes the resource name early. // Wait for the project to be ready (long-running operation) result, err := waiter.Wait(ctx) diff --git a/bundle/direct/dresources/postgres_synced_table.go b/bundle/direct/dresources/postgres_synced_table.go index 02e4c81d171..010b0e4414b 100644 --- a/bundle/direct/dresources/postgres_synced_table.go +++ b/bundle/direct/dresources/postgres_synced_table.go @@ -91,7 +91,7 @@ func (r *ResourcePostgresSyncedTable) DoRead(ctx context.Context, id string) (*P return makePostgresSyncedTableRemote(syncedTable), nil } -func (r *ResourcePostgresSyncedTable) DoCreate(ctx context.Context, engine *Engine, config *PostgresSyncedTableState) (string, *PostgresSyncedTableRemote, error) { +func (r *ResourcePostgresSyncedTable) DoCreate(ctx context.Context, _ *Engine, config *PostgresSyncedTableState) (string, *PostgresSyncedTableRemote, error) { waiter, err := r.client.Postgres.CreateSyncedTable(ctx, postgres.CreateSyncedTableRequest{ SyncedTableId: config.SyncedTableId, SyncedTable: postgres.SyncedTable{ @@ -109,7 +109,11 @@ func (r *ResourcePostgresSyncedTable) DoCreate(ctx context.Context, engine *Engi if err != nil { return "", nil, err } - engine.SaveState(ctx, waiter.Name(), config) + // TODO: save state before the wait to prevent orphaning on interruption. + // waiter.Name() returns the LRO operation name (e.g. .../operations/UUID), + // not the real resource name. We need the resource name to save a valid state + // entry; options: (1) derive it from input (Parent + resource-type + Id), + // (2) call waiter.Metadata() if it exposes the resource name early. result, err := waiter.Wait(ctx) if err != nil { From 3574599aca6ef0c64d313c512c764fecb9832044 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 5 Jun 2026 17:31:49 +0200 Subject: [PATCH 18/32] direct: route final Create/Update state saves through Engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously apply.go called db.SaveState directly after DoCreate/DoUpdate returned, bypassing the engine. This meant a DoCreate that called engine.SaveState with a wrong id (e.g. an LRO operation name instead of the real resource name) would go undetected — the final save would silently use the correct id. Route both final saves through the engine instead: - If DoCreate/DoUpdate called engine.SaveState with a wrong id, the engine's id-mismatch check panics, catching the bug immediately in tests. - If DoCreate/DoUpdate already saved identical state (e.g. the resource saved before a long wait), the engine's dedup skips the redundant write. - State-save I/O errors are logged internally by the engine and no longer abort the deployment (the resource was already created/updated successfully; aborting would confuse the user). UpdateWithID is left unchanged: DoUpdateWithID takes no Engine parameter. Co-authored-by: Denis Bilenko --- bundle/direct/apply.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bundle/direct/apply.go b/bundle/direct/apply.go index 19cf442784d..f32789d7a8b 100644 --- a/bundle/direct/apply.go +++ b/bundle/direct/apply.go @@ -79,10 +79,11 @@ func (d *DeploymentUnit) Create(ctx context.Context, db *dstate.DeploymentState, return err } - err = db.SaveState(d.ResourceKey, newID, newState, d.DependsOn) - if err != nil { - return fmt.Errorf("saving state after creating id=%s: %w", newID, err) - } + // Route the final save through the engine so that the id-mismatch check catches + // any DoCreate implementation that called engine.SaveState with a wrong id (e.g. + // an LRO operation name instead of the real resource name). The engine also + // deduplicates: if DoCreate already saved identical state, this is a no-op. + engine.SaveState(ctx, newID, newState) return nil } @@ -144,10 +145,9 @@ func (d *DeploymentUnit) Update(ctx context.Context, db *dstate.DeploymentState, return err } - err = db.SaveState(d.ResourceKey, id, newState, d.DependsOn) - if err != nil { - return fmt.Errorf("saving state id=%s: %w", id, err) - } + // Route through the engine so the id-mismatch check fires if DoUpdate saved + // under a wrong id, and to deduplicate writes when DoUpdate already saved. + engine.SaveState(ctx, id, newState) return nil } From 250161d644203d648a80cbf49d464088017ce438 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 5 Jun 2026 17:38:50 +0200 Subject: [PATCH 19/32] direct: rename Engine -> StateSaver The type does exactly one thing: save state. StateSaver names that directly. NewNopEngine -> NewNopStateSaver, engine.go -> state_saver.go. Co-authored-by: Denis Bilenko --- bundle/direct/apply.go | 4 ++-- bundle/direct/dresources/adapter.go | 12 ++++++------ bundle/direct/dresources/alert.go | 4 ++-- bundle/direct/dresources/all_test.go | 2 +- bundle/direct/dresources/app.go | 4 ++-- bundle/direct/dresources/app_test.go | 4 ++-- bundle/direct/dresources/catalog.go | 4 ++-- bundle/direct/dresources/cluster.go | 4 ++-- bundle/direct/dresources/dashboard.go | 4 ++-- bundle/direct/dresources/database_catalog.go | 2 +- bundle/direct/dresources/database_instance.go | 4 ++-- bundle/direct/dresources/experiment.go | 4 ++-- bundle/direct/dresources/external_location.go | 4 ++-- bundle/direct/dresources/grants.go | 4 ++-- bundle/direct/dresources/job.go | 4 ++-- bundle/direct/dresources/model.go | 4 ++-- .../dresources/model_serving_endpoint.go | 4 ++-- bundle/direct/dresources/permissions.go | 4 ++-- bundle/direct/dresources/pipeline.go | 4 ++-- bundle/direct/dresources/postgres_branch.go | 4 ++-- bundle/direct/dresources/postgres_catalog.go | 2 +- bundle/direct/dresources/postgres_endpoint.go | 4 ++-- bundle/direct/dresources/postgres_project.go | 4 ++-- .../direct/dresources/postgres_synced_table.go | 2 +- bundle/direct/dresources/quality_monitor.go | 4 ++-- bundle/direct/dresources/registered_model.go | 4 ++-- bundle/direct/dresources/schema.go | 4 ++-- bundle/direct/dresources/schema_test.go | 2 +- bundle/direct/dresources/secret_scope.go | 2 +- bundle/direct/dresources/secret_scope_acls.go | 4 ++-- bundle/direct/dresources/sql_warehouse.go | 4 ++-- .../dresources/{engine.go => state_saver.go} | 18 +++++++++--------- .../direct/dresources/synced_database_table.go | 2 +- .../dresources/vector_search_endpoint.go | 4 ++-- .../direct/dresources/vector_search_index.go | 2 +- bundle/direct/dresources/volume.go | 4 ++-- 36 files changed, 75 insertions(+), 75 deletions(-) rename bundle/direct/dresources/{engine.go => state_saver.go} (71%) diff --git a/bundle/direct/apply.go b/bundle/direct/apply.go index f32789d7a8b..21d362f0559 100644 --- a/bundle/direct/apply.go +++ b/bundle/direct/apply.go @@ -51,7 +51,7 @@ func (d *DeploymentUnit) Deploy(ctx context.Context, db *dstate.DeploymentState, } func (d *DeploymentUnit) Create(ctx context.Context, db *dstate.DeploymentState, newState any) error { - engine := dresources.NewEngine(d.ResourceKey, d.Adapter.StateType(), func(id string, x any) error { + engine := dresources.NewStateSaver(d.ResourceKey, d.Adapter.StateType(), func(id string, x any) error { return db.SaveState(d.ResourceKey, id, x, d.DependsOn) }) @@ -130,7 +130,7 @@ func (d *DeploymentUnit) Update(ctx context.Context, db *dstate.DeploymentState, return fmt.Errorf("internal error: DoUpdate not implemented for resource %s", d.ResourceKey) } - engine := dresources.NewEngine(d.ResourceKey, d.Adapter.StateType(), func(_ string, x any) error { + engine := dresources.NewStateSaver(d.ResourceKey, d.Adapter.StateType(), func(_ string, x any) error { return db.SaveState(d.ResourceKey, id, x, d.DependsOn) }) remoteState, err := retryOnTransient(ctx, func() (any, error) { diff --git a/bundle/direct/dresources/adapter.go b/bundle/direct/dresources/adapter.go index b8fbab72946..d1681b7e728 100644 --- a/bundle/direct/dresources/adapter.go +++ b/bundle/direct/dresources/adapter.go @@ -54,13 +54,13 @@ type IResource interface { // DoCreate creates a new resource from the newState. Returns id of the resource and optionally remote state. // If remote state is available as part of the operation, return it; otherwise return nil. // Call engine.SaveState(ctx, id, state) to persist intermediate state before long-running waits. - // Example: func (r *ResourceVolume) DoCreate(ctx context.Context, _ *Engine, newState *catalog.CreateVolumeRequestContent) (string, *catalog.VolumeInfo, error) - DoCreate(ctx context.Context, engine *Engine, newState any) (id string, remoteState any, e error) + // Example: func (r *ResourceVolume) DoCreate(ctx context.Context, _ *StateSaver, newState *catalog.CreateVolumeRequestContent) (string, *catalog.VolumeInfo, error) + DoCreate(ctx context.Context, engine *StateSaver, newState any) (id string, remoteState any, e error) // [Optional] DoUpdate updates the resource. ID must not change as a result of this operation. Returns optionally remote state. // If remote state is available as part of the operation, return it; otherwise return nil. - // Example: func (r *ResourceSchema) DoUpdate(ctx context.Context, _ *Engine, id string, newState *catalog.CreateSchema, entry *PlanEntry) (*catalog.SchemaInfo, error) - DoUpdate(ctx context.Context, engine *Engine, id string, newState any, entry *PlanEntry) (remoteState any, e error) + // Example: func (r *ResourceSchema) DoUpdate(ctx context.Context, _ *StateSaver, id string, newState *catalog.CreateSchema, entry *PlanEntry) (*catalog.SchemaInfo, error) + DoUpdate(ctx context.Context, engine *StateSaver, id string, newState any, entry *PlanEntry) (remoteState any, e error) // [Optional] DoUpdateWithID performs an update that may result in resource having a new ID. Returns new id and optionally remote state. DoUpdateWithID(ctx context.Context, id string, newState any) (newID string, remoteState any, e error) @@ -421,7 +421,7 @@ func normalizeNilPointer(v any) any { return v } -func (a *Adapter) DoCreate(ctx context.Context, engine *Engine, newState any) (string, any, error) { +func (a *Adapter) DoCreate(ctx context.Context, engine *StateSaver, newState any) (string, any, error) { outs, err := a.doCreate.Call(ctx, engine, newState) if err != nil { return "", nil, err @@ -439,7 +439,7 @@ func (a *Adapter) HasDoUpdate() bool { // DoUpdate updates the resource with the plan entry computed during plan. // Returns remote state if available, otherwise nil. -func (a *Adapter) DoUpdate(ctx context.Context, engine *Engine, id string, newState any, entry *PlanEntry) (any, error) { +func (a *Adapter) DoUpdate(ctx context.Context, engine *StateSaver, id string, newState any, entry *PlanEntry) (any, error) { if a.doUpdate == nil { return nil, errors.New("internal error: DoUpdate not found") } diff --git a/bundle/direct/dresources/alert.go b/bundle/direct/dresources/alert.go index af71e378d41..d648134288f 100644 --- a/bundle/direct/dresources/alert.go +++ b/bundle/direct/dresources/alert.go @@ -37,7 +37,7 @@ func (r *ResourceAlert) DoRead(ctx context.Context, id string) (*sql.AlertV2, er } // DoCreate creates the alert and returns its id. -func (r *ResourceAlert) DoCreate(ctx context.Context, _ *Engine, config *sql.AlertV2) (string, *sql.AlertV2, error) { +func (r *ResourceAlert) DoCreate(ctx context.Context, _ *StateSaver, config *sql.AlertV2) (string, *sql.AlertV2, error) { request := sql.CreateAlertV2Request{ Alert: *config, } @@ -49,7 +49,7 @@ func (r *ResourceAlert) DoCreate(ctx context.Context, _ *Engine, config *sql.Ale } // DoUpdate updates the alert in place. -func (r *ResourceAlert) DoUpdate(ctx context.Context, _ *Engine, id string, config *sql.AlertV2, _ *PlanEntry) (*sql.AlertV2, error) { +func (r *ResourceAlert) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *sql.AlertV2, _ *PlanEntry) (*sql.AlertV2, error) { request := sql.UpdateAlertV2Request{ Id: id, Alert: *config, diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 219c7354e9c..b9f9bee58d6 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -974,7 +974,7 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W require.Error(t, err) // TODO: if errors.Is(err, databricks.ErrResourceDoesNotExist) {... } - nopEngine := NewNopEngine(adapter.StateType()) + nopEngine := NewNopStateSaver(adapter.StateType()) createdID, remoteStateFromCreate, err := adapter.DoCreate(ctx, nopEngine, newState) require.NoError(t, err, "DoCreate failed state=%v", newState) require.NotEmpty(t, createdID, "ID returned from DoCreate was empty") diff --git a/bundle/direct/dresources/app.go b/bundle/direct/dresources/app.go index 75e80e41736..1c5b75eda21 100644 --- a/bundle/direct/dresources/app.go +++ b/bundle/direct/dresources/app.go @@ -116,7 +116,7 @@ func (r *ResourceApp) DoRead(ctx context.Context, id string) (*AppRemote, error) return remote, nil } -func (r *ResourceApp) DoCreate(ctx context.Context, engine *Engine, config *AppState) (string, *AppRemote, error) { +func (r *ResourceApp) DoCreate(ctx context.Context, engine *StateSaver, config *AppState) (string, *AppRemote, error) { // Start app compute only when lifecycle.started=true is explicit. // For nil (omitted) or false, use no_compute=true (do not start compute). noCompute := config.Lifecycle == nil || config.Lifecycle.Started == nil || !*config.Lifecycle.Started @@ -185,7 +185,7 @@ var UpdateMaskFields = []string{ var updateMask = strings.Join(UpdateMaskFields, ",") -func (r *ResourceApp) DoUpdate(ctx context.Context, _ *Engine, id string, config *AppState, entry *PlanEntry) (*AppRemote, error) { +func (r *ResourceApp) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *AppState, entry *PlanEntry) (*AppRemote, error) { // Deploy-only fields (source_code_path, config, // git_source, lifecycle) are not part of apps.App and thus excluded from the request body. if hasAppChanges(entry) { diff --git a/bundle/direct/dresources/app_test.go b/bundle/direct/dresources/app_test.go index ba64ba9475c..72185bfaa7f 100644 --- a/bundle/direct/dresources/app_test.go +++ b/bundle/direct/dresources/app_test.go @@ -36,7 +36,7 @@ func TestAppDoCreate_RetriesWhenAppIsDeleting(t *testing.T) { require.NoError(t, err) r := (&ResourceApp{}).New(client) - name, _, err := r.DoCreate(ctx, NewNopEngine(reflect.TypeFor[*AppState]()), &AppState{App: apps.App{Name: "test-app"}}) + name, _, err := r.DoCreate(ctx, NewNopStateSaver(reflect.TypeFor[*AppState]()), &AppState{App: apps.App{Name: "test-app"}}) require.NoError(t, err) assert.Equal(t, "test-app", name) @@ -73,7 +73,7 @@ func TestAppDoCreate_RetriesWhenGetReturnsNotFound(t *testing.T) { r := (&ResourceApp{}).New(client) ctx := t.Context() - name, _, err := r.DoCreate(ctx, NewNopEngine(reflect.TypeFor[*AppState]()), &AppState{App: apps.App{Name: "test-app"}}) + name, _, err := r.DoCreate(ctx, NewNopStateSaver(reflect.TypeFor[*AppState]()), &AppState{App: apps.App{Name: "test-app"}}) require.NoError(t, err) assert.Equal(t, "test-app", name) diff --git a/bundle/direct/dresources/catalog.go b/bundle/direct/dresources/catalog.go index 1af5c2aa93c..177248e62e1 100644 --- a/bundle/direct/dresources/catalog.go +++ b/bundle/direct/dresources/catalog.go @@ -41,7 +41,7 @@ func (r *ResourceCatalog) DoRead(ctx context.Context, id string) (*catalog.Catal return r.client.Catalogs.GetByName(ctx, id) } -func (r *ResourceCatalog) DoCreate(ctx context.Context, _ *Engine, config *catalog.CreateCatalog) (string, *catalog.CatalogInfo, error) { +func (r *ResourceCatalog) DoCreate(ctx context.Context, _ *StateSaver, config *catalog.CreateCatalog) (string, *catalog.CatalogInfo, error) { response, err := r.client.Catalogs.Create(ctx, *config) if err != nil || response == nil { return "", nil, err @@ -50,7 +50,7 @@ func (r *ResourceCatalog) DoCreate(ctx context.Context, _ *Engine, config *catal } // DoUpdate updates the catalog in place and returns remote state. -func (r *ResourceCatalog) DoUpdate(ctx context.Context, _ *Engine, id string, config *catalog.CreateCatalog, _ *PlanEntry) (*catalog.CatalogInfo, error) { +func (r *ResourceCatalog) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *catalog.CreateCatalog, _ *PlanEntry) (*catalog.CatalogInfo, error) { updateRequest := catalog.UpdateCatalog{ Comment: config.Comment, CustomMaxRetentionHours: config.CustomMaxRetentionHours, diff --git a/bundle/direct/dresources/cluster.go b/bundle/direct/dresources/cluster.go index 7db37f56097..2ac6a3e34fd 100644 --- a/bundle/direct/dresources/cluster.go +++ b/bundle/direct/dresources/cluster.go @@ -165,7 +165,7 @@ func (r *ResourceCluster) DoRead(ctx context.Context, id string) (*ClusterRemote return remote, nil } -func (r *ResourceCluster) DoCreate(ctx context.Context, engine *Engine, config *ClusterState) (string, *ClusterRemote, error) { +func (r *ResourceCluster) DoCreate(ctx context.Context, engine *StateSaver, config *ClusterState) (string, *ClusterRemote, error) { wait, err := r.client.Clusters.Create(ctx, makeCreateCluster(&config.ClusterSpec)) if err != nil { return "", nil, err @@ -204,7 +204,7 @@ func hasClusterChanges(entry *PlanEntry) bool { return entry.Changes.HasChangeExcept("lifecycle", "lifecycle.started") } -func (r *ResourceCluster) DoUpdate(ctx context.Context, _ *Engine, id string, config *ClusterState, entry *PlanEntry) (*ClusterRemote, error) { +func (r *ResourceCluster) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *ClusterState, entry *PlanEntry) (*ClusterRemote, error) { if hasClusterChanges(entry) { // Same retry as in TF provider logic // https://github.com/databricks/terraform-provider-databricks/blob/3eecd0f90cf99d7777e79a3d03c41f9b2aafb004/clusters/resource_cluster.go#L624 diff --git a/bundle/direct/dresources/dashboard.go b/bundle/direct/dresources/dashboard.go index f1e2de97cc4..86bafe31ab3 100644 --- a/bundle/direct/dresources/dashboard.go +++ b/bundle/direct/dresources/dashboard.go @@ -304,7 +304,7 @@ func responseToState(createOrUpdateResp *dashboards.Dashboard, publishResp *dash } } -func (r *ResourceDashboard) DoCreate(ctx context.Context, engine *Engine, config *DashboardState) (string, *DashboardState, error) { +func (r *ResourceDashboard) DoCreate(ctx context.Context, engine *StateSaver, config *DashboardState) (string, *DashboardState, error) { dashboard, err := prepareDashboardRequest(config) if err != nil { return "", nil, err @@ -360,7 +360,7 @@ func (r *ResourceDashboard) DoCreate(ctx context.Context, engine *Engine, config return createResp.DashboardId, responseToState(createResp, publishResp, dashboard.SerializedDashboard, config.Published), nil } -func (r *ResourceDashboard) DoUpdate(ctx context.Context, engine *Engine, id string, config *DashboardState, _ *PlanEntry) (*DashboardState, error) { +func (r *ResourceDashboard) DoUpdate(ctx context.Context, engine *StateSaver, id string, config *DashboardState, _ *PlanEntry) (*DashboardState, error) { dashboard, err := prepareDashboardRequest(config) if err != nil { return nil, err diff --git a/bundle/direct/dresources/database_catalog.go b/bundle/direct/dresources/database_catalog.go index f3970c4cc9f..7d80ab9e002 100644 --- a/bundle/direct/dresources/database_catalog.go +++ b/bundle/direct/dresources/database_catalog.go @@ -24,7 +24,7 @@ func (r *ResourceDatabaseCatalog) DoRead(ctx context.Context, id string) (*datab return r.client.Database.GetDatabaseCatalogByName(ctx, id) } -func (r *ResourceDatabaseCatalog) DoCreate(ctx context.Context, _ *Engine, config *database.DatabaseCatalog) (string, *database.DatabaseCatalog, error) { +func (r *ResourceDatabaseCatalog) DoCreate(ctx context.Context, _ *StateSaver, config *database.DatabaseCatalog) (string, *database.DatabaseCatalog, error) { result, err := r.client.Database.CreateDatabaseCatalog(ctx, database.CreateDatabaseCatalogRequest{ Catalog: *config, }) diff --git a/bundle/direct/dresources/database_instance.go b/bundle/direct/dresources/database_instance.go index c66a0bd092a..d0c46999681 100644 --- a/bundle/direct/dresources/database_instance.go +++ b/bundle/direct/dresources/database_instance.go @@ -25,7 +25,7 @@ func (d *ResourceDatabaseInstance) DoRead(ctx context.Context, id string) (*data return d.client.Database.GetDatabaseInstanceByName(ctx, id) } -func (d *ResourceDatabaseInstance) DoCreate(ctx context.Context, engine *Engine, config *database.DatabaseInstance) (string, *database.DatabaseInstance, error) { +func (d *ResourceDatabaseInstance) DoCreate(ctx context.Context, engine *StateSaver, config *database.DatabaseInstance) (string, *database.DatabaseInstance, error) { waiter, err := d.client.Database.CreateDatabaseInstance(ctx, database.CreateDatabaseInstanceRequest{ DatabaseInstance: *config, }) @@ -50,7 +50,7 @@ func (d *ResourceDatabaseInstance) DoCreate(ctx context.Context, engine *Engine, return id, nil, err } -func (d *ResourceDatabaseInstance) DoUpdate(ctx context.Context, _ *Engine, id string, config *database.DatabaseInstance, _ *PlanEntry) (*database.DatabaseInstance, error) { +func (d *ResourceDatabaseInstance) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *database.DatabaseInstance, _ *PlanEntry) (*database.DatabaseInstance, error) { request := database.UpdateDatabaseInstanceRequest{ DatabaseInstance: *config, Name: config.Name, diff --git a/bundle/direct/dresources/experiment.go b/bundle/direct/dresources/experiment.go index a77e97165c4..5aa08c1bf3e 100644 --- a/bundle/direct/dresources/experiment.go +++ b/bundle/direct/dresources/experiment.go @@ -52,7 +52,7 @@ func (r *ResourceExperiment) DoRead(ctx context.Context, id string) (*ml.Experim return result.Experiment, nil } -func (r *ResourceExperiment) DoCreate(ctx context.Context, _ *Engine, config *ml.CreateExperiment) (string, *ml.Experiment, error) { +func (r *ResourceExperiment) DoCreate(ctx context.Context, _ *StateSaver, config *ml.CreateExperiment) (string, *ml.Experiment, error) { result, err := r.client.Experiments.CreateExperiment(ctx, *config) if err != nil { return "", nil, err @@ -60,7 +60,7 @@ func (r *ResourceExperiment) DoCreate(ctx context.Context, _ *Engine, config *ml return result.ExperimentId, nil, nil } -func (r *ResourceExperiment) DoUpdate(ctx context.Context, _ *Engine, id string, config *ml.CreateExperiment, _ *PlanEntry) (*ml.Experiment, error) { +func (r *ResourceExperiment) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *ml.CreateExperiment, _ *PlanEntry) (*ml.Experiment, error) { updateReq := ml.UpdateExperiment{ ExperimentId: id, NewName: config.Name, diff --git a/bundle/direct/dresources/external_location.go b/bundle/direct/dresources/external_location.go index be29bed5830..dfe14983cde 100644 --- a/bundle/direct/dresources/external_location.go +++ b/bundle/direct/dresources/external_location.go @@ -44,7 +44,7 @@ func (r *ResourceExternalLocation) DoRead(ctx context.Context, id string) (*cata return r.client.ExternalLocations.GetByName(ctx, id) } -func (r *ResourceExternalLocation) DoCreate(ctx context.Context, _ *Engine, config *catalog.CreateExternalLocation) (string, *catalog.ExternalLocationInfo, error) { +func (r *ResourceExternalLocation) DoCreate(ctx context.Context, _ *StateSaver, config *catalog.CreateExternalLocation) (string, *catalog.ExternalLocationInfo, error) { response, err := r.client.ExternalLocations.Create(ctx, *config) if err != nil || response == nil { return "", nil, err @@ -53,7 +53,7 @@ func (r *ResourceExternalLocation) DoCreate(ctx context.Context, _ *Engine, conf } // DoUpdate updates the external location in place and returns remote state. -func (r *ResourceExternalLocation) DoUpdate(ctx context.Context, _ *Engine, id string, config *catalog.CreateExternalLocation, _ *PlanEntry) (*catalog.ExternalLocationInfo, error) { +func (r *ResourceExternalLocation) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *catalog.CreateExternalLocation, _ *PlanEntry) (*catalog.ExternalLocationInfo, error) { updateRequest := catalog.UpdateExternalLocation{ Comment: config.Comment, CredentialName: config.CredentialName, diff --git a/bundle/direct/dresources/grants.go b/bundle/direct/dresources/grants.go index 83ef12c5cf0..02de463e829 100644 --- a/bundle/direct/dresources/grants.go +++ b/bundle/direct/dresources/grants.go @@ -106,7 +106,7 @@ func (r *ResourceGrants) DoRead(ctx context.Context, id string) (*GrantsState, e }, nil } -func (r *ResourceGrants) DoCreate(ctx context.Context, engine *Engine, state *GrantsState) (string, *GrantsState, error) { +func (r *ResourceGrants) DoCreate(ctx context.Context, engine *StateSaver, state *GrantsState) (string, *GrantsState, error) { _, err := r.DoUpdate(ctx, engine, "", state, nil) if err != nil { // Grants Update is idempotent (additive PATCH), so retrying on transient errors is safe. @@ -116,7 +116,7 @@ func (r *ResourceGrants) DoCreate(ctx context.Context, engine *Engine, state *Gr return state.SecurableType + "/" + state.FullName, nil, nil } -func (r *ResourceGrants) DoUpdate(ctx context.Context, _ *Engine, _ string, state *GrantsState, entry *PlanEntry) (*GrantsState, error) { +func (r *ResourceGrants) DoUpdate(ctx context.Context, _ *StateSaver, _ string, state *GrantsState, entry *PlanEntry) (*GrantsState, error) { if state.FullName == "" { return nil, errors.New("internal error: grants full_name must be resolved before deployment") } diff --git a/bundle/direct/dresources/job.go b/bundle/direct/dresources/job.go index 20696e238c7..938b708d876 100644 --- a/bundle/direct/dresources/job.go +++ b/bundle/direct/dresources/job.go @@ -149,7 +149,7 @@ func makeJobRemote(job *jobs.Job) *JobRemote { } } -func (r *ResourceJob) DoCreate(ctx context.Context, _ *Engine, config *jobs.JobSettings) (string, *JobRemote, error) { +func (r *ResourceJob) DoCreate(ctx context.Context, _ *StateSaver, config *jobs.JobSettings) (string, *JobRemote, error) { request, err := makeCreateJob(*config) if err != nil { return "", nil, err @@ -161,7 +161,7 @@ func (r *ResourceJob) DoCreate(ctx context.Context, _ *Engine, config *jobs.JobS return strconv.FormatInt(response.JobId, 10), nil, nil } -func (r *ResourceJob) DoUpdate(ctx context.Context, _ *Engine, id string, config *jobs.JobSettings, _ *PlanEntry) (*JobRemote, error) { +func (r *ResourceJob) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *jobs.JobSettings, _ *PlanEntry) (*JobRemote, error) { request, err := makeResetJob(*config, id) if err != nil { return nil, err diff --git a/bundle/direct/dresources/model.go b/bundle/direct/dresources/model.go index f207b960cc6..9f943d07f63 100644 --- a/bundle/direct/dresources/model.go +++ b/bundle/direct/dresources/model.go @@ -64,7 +64,7 @@ func (r *ResourceMlflowModel) DoRead(ctx context.Context, id string) (*MlflowMod }, nil } -func (r *ResourceMlflowModel) DoCreate(ctx context.Context, _ *Engine, config *ml.CreateModelRequest) (string, *MlflowModelRemote, error) { +func (r *ResourceMlflowModel) DoCreate(ctx context.Context, _ *StateSaver, config *ml.CreateModelRequest) (string, *MlflowModelRemote, error) { response, err := r.client.ModelRegistry.CreateModel(ctx, *config) if err != nil { return "", nil, err @@ -77,7 +77,7 @@ func (r *ResourceMlflowModel) DoCreate(ctx context.Context, _ *Engine, config *m return response.RegisteredModel.Name, nil, nil } -func (r *ResourceMlflowModel) DoUpdate(ctx context.Context, _ *Engine, id string, config *ml.CreateModelRequest, entry *PlanEntry) (*MlflowModelRemote, error) { +func (r *ResourceMlflowModel) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *ml.CreateModelRequest, entry *PlanEntry) (*MlflowModelRemote, error) { updateRequest := ml.UpdateModelRequest{ Name: id, Description: config.Description, diff --git a/bundle/direct/dresources/model_serving_endpoint.go b/bundle/direct/dresources/model_serving_endpoint.go index bf985f5e491..195aad56da0 100644 --- a/bundle/direct/dresources/model_serving_endpoint.go +++ b/bundle/direct/dresources/model_serving_endpoint.go @@ -151,7 +151,7 @@ func (r *ResourceModelServingEndpoint) DoRead(ctx context.Context, id string) (* return newModelServingEndpointRemote(endpoint), nil } -func (r *ResourceModelServingEndpoint) DoCreate(ctx context.Context, engine *Engine, config *serving.CreateServingEndpoint) (string, *ModelServingEndpointRemote, error) { +func (r *ResourceModelServingEndpoint) DoCreate(ctx context.Context, engine *StateSaver, config *serving.CreateServingEndpoint) (string, *ModelServingEndpointRemote, error) { waiter, err := r.client.ServingEndpoints.Create(ctx, *config) if err != nil { return "", nil, err @@ -307,7 +307,7 @@ func (r *ResourceModelServingEndpoint) updateTags(ctx context.Context, id string return nil } -func (r *ResourceModelServingEndpoint) DoUpdate(ctx context.Context, engine *Engine, id string, config *serving.CreateServingEndpoint, entry *PlanEntry) (*ModelServingEndpointRemote, error) { +func (r *ResourceModelServingEndpoint) DoUpdate(ctx context.Context, engine *StateSaver, id string, config *serving.CreateServingEndpoint, entry *PlanEntry) (*ModelServingEndpointRemote, error) { var err error // Terraform makes these API calls sequentially. We do the same here. diff --git a/bundle/direct/dresources/permissions.go b/bundle/direct/dresources/permissions.go index 26387a4530b..06208879c9b 100644 --- a/bundle/direct/dresources/permissions.go +++ b/bundle/direct/dresources/permissions.go @@ -217,7 +217,7 @@ func (r *ResourcePermissions) DoRead(ctx context.Context, id string) (*Permissio } // DoCreate calls https://docs.databricks.com/api/workspace/jobs/setjobpermissions. -func (r *ResourcePermissions) DoCreate(ctx context.Context, engine *Engine, newState *PermissionsState) (string, *PermissionsState, error) { +func (r *ResourcePermissions) DoCreate(ctx context.Context, engine *StateSaver, newState *PermissionsState) (string, *PermissionsState, error) { // should we remember the default here? _, err := r.DoUpdate(ctx, engine, newState.ObjectID, newState, nil) if err != nil { @@ -229,7 +229,7 @@ func (r *ResourcePermissions) DoCreate(ctx context.Context, engine *Engine, newS } // DoUpdate calls https://docs.databricks.com/api/workspace/jobs/setjobpermissions. -func (r *ResourcePermissions) DoUpdate(ctx context.Context, _ *Engine, _ string, newState *PermissionsState, _ *PlanEntry) (*PermissionsState, error) { +func (r *ResourcePermissions) DoUpdate(ctx context.Context, _ *StateSaver, _ string, newState *PermissionsState, _ *PlanEntry) (*PermissionsState, error) { extractedType, extractedID, err := parsePermissionsID(newState.ObjectID) if err != nil { return nil, err diff --git a/bundle/direct/dresources/pipeline.go b/bundle/direct/dresources/pipeline.go index 3d77056d6aa..dc6bfe9ef62 100644 --- a/bundle/direct/dresources/pipeline.go +++ b/bundle/direct/dresources/pipeline.go @@ -124,7 +124,7 @@ func makePipelineRemote(p *pipelines.GetPipelineResponse) *PipelineRemote { } } -func (r *ResourcePipeline) DoCreate(ctx context.Context, _ *Engine, config *pipelines.CreatePipeline) (string, *PipelineRemote, error) { +func (r *ResourcePipeline) DoCreate(ctx context.Context, _ *StateSaver, config *pipelines.CreatePipeline) (string, *PipelineRemote, error) { response, err := r.client.Pipelines.Create(ctx, *config) if err != nil { return "", nil, err @@ -132,7 +132,7 @@ func (r *ResourcePipeline) DoCreate(ctx context.Context, _ *Engine, config *pipe return response.PipelineId, nil, nil } -func (r *ResourcePipeline) DoUpdate(ctx context.Context, _ *Engine, id string, config *pipelines.CreatePipeline, _ *PlanEntry) (*PipelineRemote, error) { +func (r *ResourcePipeline) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *pipelines.CreatePipeline, _ *PlanEntry) (*PipelineRemote, error) { request := pipelines.EditPipeline{ AllowDuplicateNames: config.AllowDuplicateNames, BudgetPolicyId: config.BudgetPolicyId, diff --git a/bundle/direct/dresources/postgres_branch.go b/bundle/direct/dresources/postgres_branch.go index 58d4eddab07..d70b94913f7 100644 --- a/bundle/direct/dresources/postgres_branch.go +++ b/bundle/direct/dresources/postgres_branch.go @@ -106,7 +106,7 @@ func (r *ResourcePostgresBranch) DoRead(ctx context.Context, id string) (*Postgr return makePostgresBranchRemote(branch), nil } -func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, _ *Engine, config *PostgresBranchState) (string, *PostgresBranchRemote, error) { +func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, _ *StateSaver, config *PostgresBranchState) (string, *PostgresBranchRemote, error) { waiter, err := r.client.Postgres.CreateBranch(ctx, postgres.CreateBranchRequest{ BranchId: config.BranchId, Parent: config.Parent, @@ -145,7 +145,7 @@ func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, _ *Engine, config return remote.Name, remote, nil } -func (r *ResourcePostgresBranch) DoUpdate(ctx context.Context, _ *Engine, id string, config *PostgresBranchState, entry *PlanEntry) (*PostgresBranchRemote, error) { +func (r *ResourcePostgresBranch) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *PostgresBranchState, entry *PlanEntry) (*PostgresBranchRemote, error) { // Build the mask from the plan's change list and prefix with "spec." (the // API expects paths relative to Branch). The API rejects mask entries // that aren't also populated in the request body, and a wildcard "*" diff --git a/bundle/direct/dresources/postgres_catalog.go b/bundle/direct/dresources/postgres_catalog.go index e47df4da1c5..50803335701 100644 --- a/bundle/direct/dresources/postgres_catalog.go +++ b/bundle/direct/dresources/postgres_catalog.go @@ -91,7 +91,7 @@ func (r *ResourcePostgresCatalog) DoRead(ctx context.Context, id string) (*Postg return makePostgresCatalogRemote(catalog), nil } -func (r *ResourcePostgresCatalog) DoCreate(ctx context.Context, _ *Engine, config *PostgresCatalogState) (string, *PostgresCatalogRemote, error) { +func (r *ResourcePostgresCatalog) DoCreate(ctx context.Context, _ *StateSaver, config *PostgresCatalogState) (string, *PostgresCatalogRemote, error) { waiter, err := r.client.Postgres.CreateCatalog(ctx, postgres.CreateCatalogRequest{ CatalogId: config.CatalogId, Catalog: postgres.Catalog{ diff --git a/bundle/direct/dresources/postgres_endpoint.go b/bundle/direct/dresources/postgres_endpoint.go index 497f6766390..e20dcc176af 100644 --- a/bundle/direct/dresources/postgres_endpoint.go +++ b/bundle/direct/dresources/postgres_endpoint.go @@ -137,7 +137,7 @@ func (r *ResourcePostgresEndpoint) waitForReconciliation(ctx context.Context, na } } -func (r *ResourcePostgresEndpoint) DoCreate(ctx context.Context, _ *Engine, config *PostgresEndpointState) (string, *PostgresEndpointRemote, error) { +func (r *ResourcePostgresEndpoint) DoCreate(ctx context.Context, _ *StateSaver, config *PostgresEndpointState) (string, *PostgresEndpointRemote, error) { waiter, err := r.client.Postgres.CreateEndpoint(ctx, postgres.CreateEndpointRequest{ EndpointId: config.EndpointId, Parent: config.Parent, @@ -181,7 +181,7 @@ func (r *ResourcePostgresEndpoint) DoCreate(ctx context.Context, _ *Engine, conf return remote.Name, remote, nil } -func (r *ResourcePostgresEndpoint) DoUpdate(ctx context.Context, _ *Engine, id string, config *PostgresEndpointState, entry *PlanEntry) (*PostgresEndpointRemote, error) { +func (r *ResourcePostgresEndpoint) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *PostgresEndpointState, entry *PlanEntry) (*PostgresEndpointRemote, error) { // Build update mask from fields that have action="update" in the changes map. // This excludes immutable fields and fields that haven't changed. // Prefix with "spec." because the API expects paths relative to the Endpoint object, diff --git a/bundle/direct/dresources/postgres_project.go b/bundle/direct/dresources/postgres_project.go index f588507a64a..9684178aba4 100644 --- a/bundle/direct/dresources/postgres_project.go +++ b/bundle/direct/dresources/postgres_project.go @@ -102,7 +102,7 @@ func (r *ResourcePostgresProject) DoRead(ctx context.Context, id string) (*Postg return makePostgresProjectRemote(project), nil } -func (r *ResourcePostgresProject) DoCreate(ctx context.Context, _ *Engine, config *PostgresProjectState) (string, *PostgresProjectRemote, error) { +func (r *ResourcePostgresProject) DoCreate(ctx context.Context, _ *StateSaver, config *PostgresProjectState) (string, *PostgresProjectRemote, error) { waiter, err := r.client.Postgres.CreateProject(ctx, postgres.CreateProjectRequest{ ProjectId: config.ProjectId, Project: postgres.Project{ @@ -141,7 +141,7 @@ func (r *ResourcePostgresProject) DoCreate(ctx context.Context, _ *Engine, confi return remote.Name, remote, nil } -func (r *ResourcePostgresProject) DoUpdate(ctx context.Context, _ *Engine, id string, config *PostgresProjectState, entry *PlanEntry) (*PostgresProjectRemote, error) { +func (r *ResourcePostgresProject) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *PostgresProjectState, entry *PlanEntry) (*PostgresProjectRemote, error) { // Build the mask from the plan's change list and prefix with "spec." (the // API expects paths relative to Project). The API rejects mask entries // that aren't also populated in the request body, and a wildcard "*" diff --git a/bundle/direct/dresources/postgres_synced_table.go b/bundle/direct/dresources/postgres_synced_table.go index 010b0e4414b..5fee998f33e 100644 --- a/bundle/direct/dresources/postgres_synced_table.go +++ b/bundle/direct/dresources/postgres_synced_table.go @@ -91,7 +91,7 @@ func (r *ResourcePostgresSyncedTable) DoRead(ctx context.Context, id string) (*P return makePostgresSyncedTableRemote(syncedTable), nil } -func (r *ResourcePostgresSyncedTable) DoCreate(ctx context.Context, _ *Engine, config *PostgresSyncedTableState) (string, *PostgresSyncedTableRemote, error) { +func (r *ResourcePostgresSyncedTable) DoCreate(ctx context.Context, _ *StateSaver, config *PostgresSyncedTableState) (string, *PostgresSyncedTableRemote, error) { waiter, err := r.client.Postgres.CreateSyncedTable(ctx, postgres.CreateSyncedTableRequest{ SyncedTableId: config.SyncedTableId, SyncedTable: postgres.SyncedTable{ diff --git a/bundle/direct/dresources/quality_monitor.go b/bundle/direct/dresources/quality_monitor.go index 7a0473ab543..b1e82f92cc9 100644 --- a/bundle/direct/dresources/quality_monitor.go +++ b/bundle/direct/dresources/quality_monitor.go @@ -72,7 +72,7 @@ func (r *ResourceQualityMonitor) DoRead(ctx context.Context, id string) (*catalo }) } -func (r *ResourceQualityMonitor) DoCreate(ctx context.Context, _ *Engine, config *QualityMonitorState) (string, *catalog.MonitorInfo, error) { +func (r *ResourceQualityMonitor) DoCreate(ctx context.Context, _ *StateSaver, config *QualityMonitorState) (string, *catalog.MonitorInfo, error) { req := config.CreateMonitor req.TableName = config.TableName //nolint:staticcheck // Direct quality_monitor resource still uses legacy monitor endpoints; v1 data-quality migration is separate work. @@ -83,7 +83,7 @@ func (r *ResourceQualityMonitor) DoCreate(ctx context.Context, _ *Engine, config return response.TableName, response, nil } -func (r *ResourceQualityMonitor) DoUpdate(ctx context.Context, _ *Engine, id string, config *QualityMonitorState, _ *PlanEntry) (*catalog.MonitorInfo, error) { +func (r *ResourceQualityMonitor) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *QualityMonitorState, _ *PlanEntry) (*catalog.MonitorInfo, error) { updateRequest := catalog.UpdateMonitor{ TableName: id, BaselineTableName: config.BaselineTableName, diff --git a/bundle/direct/dresources/registered_model.go b/bundle/direct/dresources/registered_model.go index b02d901556d..e2ebcfcc0ab 100644 --- a/bundle/direct/dresources/registered_model.go +++ b/bundle/direct/dresources/registered_model.go @@ -56,7 +56,7 @@ func (r *ResourceRegisteredModel) DoRead(ctx context.Context, id string) (*catal }) } -func (r *ResourceRegisteredModel) DoCreate(ctx context.Context, _ *Engine, config *catalog.CreateRegisteredModelRequest) (string, *catalog.RegisteredModelInfo, error) { +func (r *ResourceRegisteredModel) DoCreate(ctx context.Context, _ *StateSaver, config *catalog.CreateRegisteredModelRequest) (string, *catalog.RegisteredModelInfo, error) { response, err := r.client.RegisteredModels.Create(ctx, *config) if err != nil { return "", nil, err @@ -65,7 +65,7 @@ func (r *ResourceRegisteredModel) DoCreate(ctx context.Context, _ *Engine, confi return response.FullName, response, nil } -func (r *ResourceRegisteredModel) DoUpdate(ctx context.Context, _ *Engine, id string, config *catalog.CreateRegisteredModelRequest, _ *PlanEntry) (*catalog.RegisteredModelInfo, error) { +func (r *ResourceRegisteredModel) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *catalog.CreateRegisteredModelRequest, _ *PlanEntry) (*catalog.RegisteredModelInfo, error) { updateRequest := catalog.UpdateRegisteredModelRequest{ FullName: id, Comment: config.Comment, diff --git a/bundle/direct/dresources/schema.go b/bundle/direct/dresources/schema.go index 0e5716b4882..241dd8e5b3c 100644 --- a/bundle/direct/dresources/schema.go +++ b/bundle/direct/dresources/schema.go @@ -38,7 +38,7 @@ func (r *ResourceSchema) DoRead(ctx context.Context, id string) (*catalog.Schema return r.client.Schemas.GetByFullName(ctx, id) } -func (r *ResourceSchema) DoCreate(ctx context.Context, _ *Engine, config *catalog.CreateSchema) (string, *catalog.SchemaInfo, error) { +func (r *ResourceSchema) DoCreate(ctx context.Context, _ *StateSaver, config *catalog.CreateSchema) (string, *catalog.SchemaInfo, error) { response, err := r.client.Schemas.Create(ctx, *config) if err != nil || response == nil { return "", nil, err @@ -47,7 +47,7 @@ func (r *ResourceSchema) DoCreate(ctx context.Context, _ *Engine, config *catalo } // DoUpdate updates the schema in place and returns remote state. -func (r *ResourceSchema) DoUpdate(ctx context.Context, _ *Engine, id string, config *catalog.CreateSchema, _ *PlanEntry) (*catalog.SchemaInfo, error) { +func (r *ResourceSchema) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *catalog.CreateSchema, _ *PlanEntry) (*catalog.SchemaInfo, error) { updateRequest := catalog.UpdateSchema{ Comment: config.Comment, CustomMaxRetentionHours: config.CustomMaxRetentionHours, diff --git a/bundle/direct/dresources/schema_test.go b/bundle/direct/dresources/schema_test.go index e3a66bf4cc6..fbb0e6c4dff 100644 --- a/bundle/direct/dresources/schema_test.go +++ b/bundle/direct/dresources/schema_test.go @@ -25,7 +25,7 @@ func TestResourceSchema_DoUpdate_WithUnsupportedForceSendFields(t *testing.T) { ForceSendFields: nil, } - nopEngine := NewNopEngine(reflect.TypeOf(config)) + nopEngine := NewNopStateSaver(reflect.TypeOf(config)) id, _, err := adapter.DoCreate(ctx, nopEngine, config) require.NoError(t, err) diff --git a/bundle/direct/dresources/secret_scope.go b/bundle/direct/dresources/secret_scope.go index a77fb1ebd3c..2a562e7898e 100644 --- a/bundle/direct/dresources/secret_scope.go +++ b/bundle/direct/dresources/secret_scope.go @@ -66,7 +66,7 @@ func (r *ResourceSecretScope) DoRead(ctx context.Context, id string) (*workspace return nil, fmt.Errorf("secret scope %q not found", id) } -func (r *ResourceSecretScope) DoCreate(ctx context.Context, _ *Engine, state *SecretScopeConfig) (string, *workspace.SecretScope, error) { +func (r *ResourceSecretScope) DoCreate(ctx context.Context, _ *StateSaver, state *SecretScopeConfig) (string, *workspace.SecretScope, error) { err := r.client.Secrets.CreateScope(ctx, state.CreateScope) if err != nil { return "", nil, err diff --git a/bundle/direct/dresources/secret_scope_acls.go b/bundle/direct/dresources/secret_scope_acls.go index 14cd05b8f91..1d7ed208893 100644 --- a/bundle/direct/dresources/secret_scope_acls.go +++ b/bundle/direct/dresources/secret_scope_acls.go @@ -92,7 +92,7 @@ func (r *ResourceSecretScopeAcls) RemapState(remote *SecretScopeAclsState) *Secr return remote } -func (r *ResourceSecretScopeAcls) DoCreate(ctx context.Context, _ *Engine, state *SecretScopeAclsState) (string, *SecretScopeAclsState, error) { +func (r *ResourceSecretScopeAcls) DoCreate(ctx context.Context, _ *StateSaver, state *SecretScopeAclsState) (string, *SecretScopeAclsState, error) { err := r.setACLs(ctx, state.ScopeName, state.Acls) if err != nil { return "", nil, err @@ -109,7 +109,7 @@ func (r *ResourceSecretScopeAcls) DoUpdateWithID(ctx context.Context, id string, return state.ScopeName, nil, nil } -func (r *ResourceSecretScopeAcls) DoUpdate(ctx context.Context, _ *Engine, id string, state *SecretScopeAclsState, _ *PlanEntry) (*SecretScopeAclsState, error) { +func (r *ResourceSecretScopeAcls) DoUpdate(ctx context.Context, _ *StateSaver, id string, state *SecretScopeAclsState, _ *PlanEntry) (*SecretScopeAclsState, error) { _, _, err := r.DoUpdateWithID(ctx, id, state) return nil, err } diff --git a/bundle/direct/dresources/sql_warehouse.go b/bundle/direct/dresources/sql_warehouse.go index ba584c84126..61024437ecb 100644 --- a/bundle/direct/dresources/sql_warehouse.go +++ b/bundle/direct/dresources/sql_warehouse.go @@ -118,7 +118,7 @@ func (r *ResourceSqlWarehouse) DoRead(ctx context.Context, id string) (*SqlWareh } // DoCreate creates the warehouse and returns its id. -func (r *ResourceSqlWarehouse) DoCreate(ctx context.Context, engine *Engine, config *SqlWarehouseState) (string, *SqlWarehouseRemote, error) { +func (r *ResourceSqlWarehouse) DoCreate(ctx context.Context, engine *StateSaver, config *SqlWarehouseState) (string, *SqlWarehouseRemote, error) { waiter, err := r.client.Warehouses.Create(ctx, config.CreateWarehouseRequest) if err != nil { return "", nil, err @@ -161,7 +161,7 @@ func hasWarehouseChanges(entry *PlanEntry) bool { } // DoUpdate updates the warehouse in place. -func (r *ResourceSqlWarehouse) DoUpdate(ctx context.Context, _ *Engine, id string, config *SqlWarehouseState, entry *PlanEntry) (*SqlWarehouseRemote, error) { +func (r *ResourceSqlWarehouse) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *SqlWarehouseState, entry *PlanEntry) (*SqlWarehouseRemote, error) { edited := hasWarehouseChanges(entry) if edited { request := sql.EditWarehouseRequest{ diff --git a/bundle/direct/dresources/engine.go b/bundle/direct/dresources/state_saver.go similarity index 71% rename from bundle/direct/dresources/engine.go rename to bundle/direct/dresources/state_saver.go index 0172f287ff1..439816b002c 100644 --- a/bundle/direct/dresources/engine.go +++ b/bundle/direct/dresources/state_saver.go @@ -11,10 +11,10 @@ import ( "github.com/databricks/cli/libs/structs/structdiff" ) -// Engine provides state persistence to resource implementations. +// StateSaver provides state persistence to resource implementations. // Pass it to DoCreate or DoUpdate to save intermediate state before long-running // wait operations, so the resource is not orphaned if deployment is interrupted. -type Engine struct { +type StateSaver struct { resourceKey string id string stateType reflect.Type @@ -22,15 +22,15 @@ type Engine struct { lastSaved any } -// NewEngine creates an Engine with the given state type and save function. +// NewStateSaver creates an StateSaver with the given state type and save function. // The framework calls this before invoking DoCreate or DoUpdate. -func NewEngine(resourceKey string, stateType reflect.Type, saveFunc func(id string, x any) error) *Engine { - return &Engine{resourceKey: resourceKey, id: "", stateType: stateType, saveFunc: saveFunc, lastSaved: nil} +func NewStateSaver(resourceKey string, stateType reflect.Type, saveFunc func(id string, x any) error) *StateSaver { + return &StateSaver{resourceKey: resourceKey, id: "", stateType: stateType, saveFunc: saveFunc, lastSaved: nil} } -// NewNopEngine creates an Engine that discards all saves. Use in tests. -func NewNopEngine(stateType reflect.Type) *Engine { - return NewEngine("", stateType, func(_ string, _ any) error { return nil }) +// NewNopStateSaver creates an StateSaver that discards all saves. Use in tests. +func NewNopStateSaver(stateType reflect.Type) *StateSaver { + return NewStateSaver("", stateType, func(_ string, _ any) error { return nil }) } // SaveState saves the resource state. id must be the resource's identifier; on @@ -39,7 +39,7 @@ func NewNopEngine(stateType reflect.Type) *Engine { // If the state is identical to what was last saved, the write is skipped. // Failures to persist state are logged but do not abort the deployment — the // resource already exists and aborting would not undo its creation. -func (e *Engine) SaveState(ctx context.Context, id string, x any) { +func (e *StateSaver) SaveState(ctx context.Context, id string, x any) { if e.id == "" { e.id = id } else if e.id != id { diff --git a/bundle/direct/dresources/synced_database_table.go b/bundle/direct/dresources/synced_database_table.go index 05d40f1bb41..06b3805c862 100644 --- a/bundle/direct/dresources/synced_database_table.go +++ b/bundle/direct/dresources/synced_database_table.go @@ -24,7 +24,7 @@ func (r *ResourceSyncedDatabaseTable) DoRead(ctx context.Context, name string) ( return r.client.Database.GetSyncedDatabaseTableByName(ctx, name) } -func (r *ResourceSyncedDatabaseTable) DoCreate(ctx context.Context, _ *Engine, config *database.SyncedDatabaseTable) (string, *database.SyncedDatabaseTable, error) { +func (r *ResourceSyncedDatabaseTable) DoCreate(ctx context.Context, _ *StateSaver, config *database.SyncedDatabaseTable) (string, *database.SyncedDatabaseTable, error) { result, err := r.client.Database.CreateSyncedDatabaseTable(ctx, database.CreateSyncedDatabaseTableRequest{ SyncedTable: *config, }) diff --git a/bundle/direct/dresources/vector_search_endpoint.go b/bundle/direct/dresources/vector_search_endpoint.go index 9f7518cc937..f1b6f151bcc 100644 --- a/bundle/direct/dresources/vector_search_endpoint.go +++ b/bundle/direct/dresources/vector_search_endpoint.go @@ -80,7 +80,7 @@ func (r *ResourceVectorSearchEndpoint) DoRead(ctx context.Context, id string) (* return newVectorSearchEndpointRemote(info), nil } -func (r *ResourceVectorSearchEndpoint) DoCreate(ctx context.Context, engine *Engine, config *vectorsearch.CreateEndpoint) (string, *VectorSearchEndpointRemote, error) { +func (r *ResourceVectorSearchEndpoint) DoCreate(ctx context.Context, engine *StateSaver, config *vectorsearch.CreateEndpoint) (string, *VectorSearchEndpointRemote, error) { _, err := r.client.VectorSearchEndpoints.CreateEndpoint(ctx, *config) if err != nil { return "", nil, err @@ -98,7 +98,7 @@ func (r *ResourceVectorSearchEndpoint) DoCreate(ctx context.Context, engine *Eng return id, newVectorSearchEndpointRemote(info), nil } -func (r *ResourceVectorSearchEndpoint) DoUpdate(ctx context.Context, _ *Engine, id string, config *vectorsearch.CreateEndpoint, entry *PlanEntry) (*VectorSearchEndpointRemote, error) { +func (r *ResourceVectorSearchEndpoint) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *vectorsearch.CreateEndpoint, entry *PlanEntry) (*VectorSearchEndpointRemote, error) { if entry.Changes.HasChange(pathBudgetPolicyId) { _, err := r.client.VectorSearchEndpoints.UpdateEndpointBudgetPolicy(ctx, vectorsearch.PatchEndpointBudgetPolicyRequest{ EndpointName: id, diff --git a/bundle/direct/dresources/vector_search_index.go b/bundle/direct/dresources/vector_search_index.go index 98ff5be1fc6..81a229cee2c 100644 --- a/bundle/direct/dresources/vector_search_index.go +++ b/bundle/direct/dresources/vector_search_index.go @@ -128,7 +128,7 @@ func (r *ResourceVectorSearchIndex) DoRead(ctx context.Context, id string) (*Vec }, nil } -func (r *ResourceVectorSearchIndex) DoCreate(ctx context.Context, engine *Engine, config *VectorSearchIndexState) (string, *VectorSearchIndexRemote, error) { +func (r *ResourceVectorSearchIndex) DoCreate(ctx context.Context, engine *StateSaver, config *VectorSearchIndexState) (string, *VectorSearchIndexRemote, error) { _, err := r.createIndex(ctx, config.CreateVectorIndexRequest) if err != nil { return "", nil, err diff --git a/bundle/direct/dresources/volume.go b/bundle/direct/dresources/volume.go index 3bb057f6958..05fc9d00374 100644 --- a/bundle/direct/dresources/volume.go +++ b/bundle/direct/dresources/volume.go @@ -40,7 +40,7 @@ func (r *ResourceVolume) DoRead(ctx context.Context, id string) (*catalog.Volume return r.client.Volumes.ReadByName(ctx, id) } -func (r *ResourceVolume) DoCreate(ctx context.Context, _ *Engine, config *catalog.CreateVolumeRequestContent) (string, *catalog.VolumeInfo, error) { +func (r *ResourceVolume) DoCreate(ctx context.Context, _ *StateSaver, config *catalog.CreateVolumeRequestContent) (string, *catalog.VolumeInfo, error) { response, err := r.client.Volumes.Create(ctx, *config) if err != nil { return "", nil, err @@ -48,7 +48,7 @@ func (r *ResourceVolume) DoCreate(ctx context.Context, _ *Engine, config *catalo return response.FullName, response, nil } -func (r *ResourceVolume) DoUpdate(ctx context.Context, _ *Engine, id string, config *catalog.CreateVolumeRequestContent, _ *PlanEntry) (*catalog.VolumeInfo, error) { +func (r *ResourceVolume) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *catalog.CreateVolumeRequestContent, _ *PlanEntry) (*catalog.VolumeInfo, error) { updateRequest := catalog.UpdateVolumeRequestContent{ Comment: config.Comment, Name: id, From 3bcb95efc197e514e00cd0aaacd9a1ca46fd5ff3 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 5 Jun 2026 19:46:33 +0200 Subject: [PATCH 20/32] direct: add SaveStateWith helper; avoid double JSON marshal in StateSaver - SaveStateWith[F any]: type-safe helper that temporarily sets a field to an intermediate value before saving, then restores it. Used to save Published=false before publishDashboard and Lifecycle=nil before warehouse/cluster lifecycle management, so the planner sees a real diff if deployment is interrupted mid-way. - Change saveFunc signature to func(id string, b json.RawMessage) error and add DeploymentState.SaveStateJSON to accept pre-marshaled bytes. StateSaver already marshals x to JSON for dedup; passing those bytes directly avoids a redundant marshal on every save. - Fix lastSaved aliasing: store []byte (JSON snapshot) instead of any pointer. SaveStateWith modifies the config, saves, then restores; a pointer-based lastSaved would point at the restored value, making the subsequent final save look like a no-op and leaving Published=false in the WAL. Co-authored-by: Isaac --- bundle/direct/apply.go | 8 ++--- bundle/direct/dresources/cluster.go | 7 ++-- bundle/direct/dresources/dashboard.go | 16 +++------ bundle/direct/dresources/sql_warehouse.go | 7 ++-- bundle/direct/dresources/state_saver.go | 41 ++++++++++++++++++----- bundle/direct/dstate/state.go | 28 ++++++++++------ 6 files changed, 66 insertions(+), 41 deletions(-) diff --git a/bundle/direct/apply.go b/bundle/direct/apply.go index 21d362f0559..980f913ae35 100644 --- a/bundle/direct/apply.go +++ b/bundle/direct/apply.go @@ -51,8 +51,8 @@ func (d *DeploymentUnit) Deploy(ctx context.Context, db *dstate.DeploymentState, } func (d *DeploymentUnit) Create(ctx context.Context, db *dstate.DeploymentState, newState any) error { - engine := dresources.NewStateSaver(d.ResourceKey, d.Adapter.StateType(), func(id string, x any) error { - return db.SaveState(d.ResourceKey, id, x, d.DependsOn) + engine := dresources.NewStateSaver(d.ResourceKey, d.Adapter.StateType(), func(id string, b json.RawMessage) error { + return db.SaveStateJSON(d.ResourceKey, id, b, d.DependsOn) }) var newID string @@ -130,8 +130,8 @@ func (d *DeploymentUnit) Update(ctx context.Context, db *dstate.DeploymentState, return fmt.Errorf("internal error: DoUpdate not implemented for resource %s", d.ResourceKey) } - engine := dresources.NewStateSaver(d.ResourceKey, d.Adapter.StateType(), func(_ string, x any) error { - return db.SaveState(d.ResourceKey, id, x, d.DependsOn) + engine := dresources.NewStateSaver(d.ResourceKey, d.Adapter.StateType(), func(_ string, b json.RawMessage) error { + return db.SaveStateJSON(d.ResourceKey, id, b, d.DependsOn) }) remoteState, err := retryOnTransient(ctx, func() (any, error) { return d.Adapter.DoUpdate(ctx, engine, id, newState, planEntry) diff --git a/bundle/direct/dresources/cluster.go b/bundle/direct/dresources/cluster.go index 2ac6a3e34fd..996c4b697ba 100644 --- a/bundle/direct/dresources/cluster.go +++ b/bundle/direct/dresources/cluster.go @@ -172,9 +172,10 @@ func (r *ResourceCluster) DoCreate(ctx context.Context, engine *StateSaver, conf } id := wait.ClusterId - // Save state immediately after the cluster is created so it is not orphaned - // if the subsequent wait or terminate is interrupted. - engine.SaveState(ctx, id, config) + // Save with Lifecycle=nil: cluster exists but lifecycle has not been applied yet + // (it always starts RUNNING). If the subsequent wait or stop is interrupted, the + // planner sees a real diff (nil→desired) and re-applies lifecycle on the next deploy. + SaveStateWith(engine, ctx, id, config, &config.Lifecycle, (*StateLifecycle)(nil)) // Always wait for RUNNING first: clusters start in PENDING state and must be polled. _, err = r.client.Clusters.WaitGetClusterRunning(ctx, id, clusterWaitTimeout, nil) diff --git a/bundle/direct/dresources/dashboard.go b/bundle/direct/dresources/dashboard.go index 86bafe31ab3..8752aa7fb4e 100644 --- a/bundle/direct/dresources/dashboard.go +++ b/bundle/direct/dresources/dashboard.go @@ -339,14 +339,9 @@ func (r *ResourceDashboard) DoCreate(ctx context.Context, engine *StateSaver, co // Persist the etag in state. config.Etag = createResp.Etag - // Save state with Published=false: the dashboard exists as a draft; publish - // has not succeeded yet. Using Published=false ensures the planner sees a - // real diff (false→true) if publish is interrupted, triggering a DoUpdate - // on the next deploy instead of silently treating the resource as up-to-date. - savedPublished := config.Published - config.Published = false - engine.SaveState(ctx, createResp.DashboardId, config) - config.Published = savedPublished + // Save with Published=false: draft exists, publish not yet done. Ensures the + // planner sees a real diff (false→true) if publish is interrupted. + SaveStateWith(engine, ctx, createResp.DashboardId, config, &config.Published, false) var publishResp *dashboards.PublishedDashboard // Note, today config.Published is always true (we do not have this field in input config). @@ -385,10 +380,7 @@ func (r *ResourceDashboard) DoUpdate(ctx context.Context, engine *StateSaver, id // sync (a stale etag would make the next Update fail with a conflict) and records // published=false so the planner re-publishes on the next deploy. config.Etag = updateResp.Etag - savedPublished := config.Published - config.Published = false - engine.SaveState(ctx, id, config) - config.Published = savedPublished + SaveStateWith(engine, ctx, id, config, &config.Published, false) var publishResp *dashboards.PublishedDashboard // Note, today config.Published is always true (we do not have this field in input config). diff --git a/bundle/direct/dresources/sql_warehouse.go b/bundle/direct/dresources/sql_warehouse.go index 61024437ecb..39a2af37cf9 100644 --- a/bundle/direct/dresources/sql_warehouse.go +++ b/bundle/direct/dresources/sql_warehouse.go @@ -125,9 +125,10 @@ func (r *ResourceSqlWarehouse) DoCreate(ctx context.Context, engine *StateSaver, } id := waiter.Id - // Save state immediately after the warehouse is created so it is not orphaned - // if the subsequent wait or stop is interrupted. - engine.SaveState(ctx, id, config) + // Save with Lifecycle=nil: warehouse exists but lifecycle has not been applied yet + // (it always starts RUNNING). If the subsequent wait or stop is interrupted, the + // planner sees a real diff (nil→desired) and re-applies lifecycle on the next deploy. + SaveStateWith(engine, ctx, id, config, &config.Lifecycle, (*StateLifecycle)(nil)) if config.Lifecycle == nil || config.Lifecycle.Started == nil { return id, nil, nil diff --git a/bundle/direct/dresources/state_saver.go b/bundle/direct/dresources/state_saver.go index 439816b002c..3c8d605c7eb 100644 --- a/bundle/direct/dresources/state_saver.go +++ b/bundle/direct/dresources/state_saver.go @@ -1,14 +1,16 @@ package dresources import ( + "bytes" "context" "encoding/json" "fmt" "reflect" + "github.com/databricks/cli/libs/dyn" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/logdiag" - "github.com/databricks/cli/libs/structs/structdiff" + "github.com/databricks/cli/libs/structs/structwalk" ) // StateSaver provides state persistence to resource implementations. @@ -18,19 +20,33 @@ type StateSaver struct { resourceKey string id string stateType reflect.Type - saveFunc func(id string, x any) error - lastSaved any + saveFunc func(id string, b json.RawMessage) error + lastSaved []byte // JSON snapshot; stored by value to avoid aliasing with the live config pointer } // NewStateSaver creates an StateSaver with the given state type and save function. // The framework calls this before invoking DoCreate or DoUpdate. -func NewStateSaver(resourceKey string, stateType reflect.Type, saveFunc func(id string, x any) error) *StateSaver { +func NewStateSaver(resourceKey string, stateType reflect.Type, saveFunc func(id string, b json.RawMessage) error) *StateSaver { return &StateSaver{resourceKey: resourceKey, id: "", stateType: stateType, saveFunc: saveFunc, lastSaved: nil} } // NewNopStateSaver creates an StateSaver that discards all saves. Use in tests. func NewNopStateSaver(stateType reflect.Type) *StateSaver { - return NewStateSaver("", stateType, func(_ string, _ any) error { return nil }) + return NewStateSaver("", stateType, func(_ string, _ json.RawMessage) error { return nil }) +} + +// SaveStateWith saves state with field temporarily set to value, then restores it. +// This is useful when the actual current state of a field differs from its desired +// value in config — e.g. saving started=true before a stop, or published=false +// before a publish, so the planner sees a real diff if the operation is interrupted. +// +// field must be a pointer to a field within config. Type safety is enforced by the +// compiler: field and value must have the same type F. +func SaveStateWith[F any](s *StateSaver, ctx context.Context, id string, config any, field *F, value F) { + saved := *field + *field = value + s.SaveState(ctx, id, config) + *field = saved } // SaveState saves the resource state. id must be the resource's identifier; on @@ -49,19 +65,26 @@ func (e *StateSaver) SaveState(ctx context.Context, id string, x any) { if xt != e.stateType { panic(fmt.Sprintf("SaveState: type mismatch: expected %v, got %v", e.stateType, xt)) } - if e.lastSaved != nil && structdiff.IsEqual(e.lastSaved, x) { + // Redact sensitive fields before persisting: secrets must not appear on disk + // in plaintext. The saveFunc hands these bytes to SaveStateJSON, which writes + // them as-is, so redaction must happen here rather than in the state layer. + b, err := structwalk.RedactSensitiveFields(x, dyn.SensitiveValueRedacted) + if err != nil { + logdiag.LogError(ctx, err) + return + } + if bytes.Equal(e.lastSaved, b) { log.Debugf(ctx, "SaveState: %s id=%s: skipping, state unchanged", e.resourceKey, id) return } - b, _ := json.Marshal(x) preview := string(b) if len(preview) > 100 { preview = preview[:100] } log.Debugf(ctx, "SaveState: %s id=%s %d bytes: %s", e.resourceKey, id, len(b), preview) - if err := e.saveFunc(e.id, x); err != nil { + if err := e.saveFunc(e.id, b); err != nil { logdiag.LogError(ctx, err) return } - e.lastSaved = x + e.lastSaved = b } diff --git a/bundle/direct/dstate/state.go b/bundle/direct/dstate/state.go index f6c8fc8ba3c..50d476c6905 100644 --- a/bundle/direct/dstate/state.go +++ b/bundle/direct/dstate/state.go @@ -121,6 +121,22 @@ func NewDatabase(lineage string, serial int) Database { } func (db *DeploymentState) SaveState(key, newID string, state any, dependsOn []deployplan.DependsOnEntry) error { + // Redact sensitive fields before persisting: secrets must not appear on disk + // in plaintext. The original struct is not modified; the plan uses the + // unredacted in-memory value for API calls. RedactSensitiveFields marshals + // without indentation, so every WAL entry remains on a single line. + b, err := structwalk.RedactSensitiveFields(state, dyn.SensitiveValueRedacted) + if err != nil { + return err + } + return db.SaveStateJSON(key, newID, b, dependsOn) +} + +// SaveStateJSON saves pre-marshaled JSON state, avoiding a redundant marshal when +// the caller already holds the serialized bytes (e.g. the StateSaver dedup path). +// Callers are responsible for redacting sensitive fields before marshaling; the +// bytes are persisted as-is. +func (db *DeploymentState) SaveStateJSON(key, newID string, state json.RawMessage, dependsOn []deployplan.DependsOnEntry) error { db.AssertOpenedForWrite() db.mu.Lock() defer db.mu.Unlock() @@ -129,21 +145,13 @@ func (db *DeploymentState) SaveState(key, newID string, state any, dependsOn []d db.Data.State = make(map[string]ResourceEntry) } - // Redact sensitive fields before persisting: secrets must not appear on disk - // in plaintext. The original struct is not modified; the plan uses the - // unredacted in-memory value for API calls. - jsonMessage, err := structwalk.RedactSensitiveFields(state, dyn.SensitiveValueRedacted) - if err != nil { - return err - } - entry := ResourceEntry{ ID: newID, - State: json.RawMessage(jsonMessage), + State: state, DependsOn: dependsOn, } - err = appendJSONLine(db.walFile, WALEntry{Key: key, Value: &entry}) + err := appendJSONLine(db.walFile, WALEntry{Key: key, Value: &entry}) if err == nil { db.stateIDs[key] = newID } From 3e5fc241e14f440b798ea6e4e97ad27af755a090 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 5 Jun 2026 20:15:26 +0200 Subject: [PATCH 21/32] direct: fix SaveStateWith parameter order (ctx first) Co-authored-by: Isaac --- bundle/direct/dresources/cluster.go | 2 +- bundle/direct/dresources/dashboard.go | 4 ++-- bundle/direct/dresources/sql_warehouse.go | 2 +- bundle/direct/dresources/state_saver.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bundle/direct/dresources/cluster.go b/bundle/direct/dresources/cluster.go index 996c4b697ba..fccf23797d1 100644 --- a/bundle/direct/dresources/cluster.go +++ b/bundle/direct/dresources/cluster.go @@ -175,7 +175,7 @@ func (r *ResourceCluster) DoCreate(ctx context.Context, engine *StateSaver, conf // Save with Lifecycle=nil: cluster exists but lifecycle has not been applied yet // (it always starts RUNNING). If the subsequent wait or stop is interrupted, the // planner sees a real diff (nil→desired) and re-applies lifecycle on the next deploy. - SaveStateWith(engine, ctx, id, config, &config.Lifecycle, (*StateLifecycle)(nil)) + SaveStateWith(ctx, engine, id, config, &config.Lifecycle, (*StateLifecycle)(nil)) // Always wait for RUNNING first: clusters start in PENDING state and must be polled. _, err = r.client.Clusters.WaitGetClusterRunning(ctx, id, clusterWaitTimeout, nil) diff --git a/bundle/direct/dresources/dashboard.go b/bundle/direct/dresources/dashboard.go index 8752aa7fb4e..99cdd2c904a 100644 --- a/bundle/direct/dresources/dashboard.go +++ b/bundle/direct/dresources/dashboard.go @@ -341,7 +341,7 @@ func (r *ResourceDashboard) DoCreate(ctx context.Context, engine *StateSaver, co config.Etag = createResp.Etag // Save with Published=false: draft exists, publish not yet done. Ensures the // planner sees a real diff (false→true) if publish is interrupted. - SaveStateWith(engine, ctx, createResp.DashboardId, config, &config.Published, false) + SaveStateWith(ctx, engine, createResp.DashboardId, config, &config.Published, false) var publishResp *dashboards.PublishedDashboard // Note, today config.Published is always true (we do not have this field in input config). @@ -380,7 +380,7 @@ func (r *ResourceDashboard) DoUpdate(ctx context.Context, engine *StateSaver, id // sync (a stale etag would make the next Update fail with a conflict) and records // published=false so the planner re-publishes on the next deploy. config.Etag = updateResp.Etag - SaveStateWith(engine, ctx, id, config, &config.Published, false) + SaveStateWith(ctx, engine, id, config, &config.Published, false) var publishResp *dashboards.PublishedDashboard // Note, today config.Published is always true (we do not have this field in input config). diff --git a/bundle/direct/dresources/sql_warehouse.go b/bundle/direct/dresources/sql_warehouse.go index 39a2af37cf9..54387d7ade7 100644 --- a/bundle/direct/dresources/sql_warehouse.go +++ b/bundle/direct/dresources/sql_warehouse.go @@ -128,7 +128,7 @@ func (r *ResourceSqlWarehouse) DoCreate(ctx context.Context, engine *StateSaver, // Save with Lifecycle=nil: warehouse exists but lifecycle has not been applied yet // (it always starts RUNNING). If the subsequent wait or stop is interrupted, the // planner sees a real diff (nil→desired) and re-applies lifecycle on the next deploy. - SaveStateWith(engine, ctx, id, config, &config.Lifecycle, (*StateLifecycle)(nil)) + SaveStateWith(ctx, engine, id, config, &config.Lifecycle, (*StateLifecycle)(nil)) if config.Lifecycle == nil || config.Lifecycle.Started == nil { return id, nil, nil diff --git a/bundle/direct/dresources/state_saver.go b/bundle/direct/dresources/state_saver.go index 3c8d605c7eb..5cd3e489643 100644 --- a/bundle/direct/dresources/state_saver.go +++ b/bundle/direct/dresources/state_saver.go @@ -42,7 +42,7 @@ func NewNopStateSaver(stateType reflect.Type) *StateSaver { // // field must be a pointer to a field within config. Type safety is enforced by the // compiler: field and value must have the same type F. -func SaveStateWith[F any](s *StateSaver, ctx context.Context, id string, config any, field *F, value F) { +func SaveStateWith[F any](ctx context.Context, s *StateSaver, id string, config any, field *F, value F) { saved := *field *field = value s.SaveState(ctx, id, config) From ed4de44fc3357d067673d5651f026dba86bae6ec Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Sun, 7 Jun 2026 11:04:00 +0200 Subject: [PATCH 22/32] direct: use SaveStateWith in app DoCreate to prevent un-deployed app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If DoCreate is interrupted after the app is created but before manageLifecycle completes (during waitForApp or the deploy step), the app can reach ACTIVE on its own with no active deployment. Previously, engine.SaveState saved lifecycle.started=true (the desired value). On the next plan the planner would see no localDiff (state == desired) and no remoteDiff for lifecycle (remote is already ACTIVE), while source_code_path drift is silently skipped by OverrideChangeDesc when the remote has no active deployment. Result: the planner marks the resource as Skip, and the app stays permanently un-deployed. SaveStateWith(lifecycle=nil) records that the app exists but lifecycle has not been applied yet. This creates a localDiff (nil→desired) that is not remote-skippable, forcing DoUpdate → manageLifecycle → Deploy on the next run. Co-authored-by: Isaac --- bundle/direct/dresources/app.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bundle/direct/dresources/app.go b/bundle/direct/dresources/app.go index 1c5b75eda21..216b7c91782 100644 --- a/bundle/direct/dresources/app.go +++ b/bundle/direct/dresources/app.go @@ -154,10 +154,13 @@ func (r *ResourceApp) DoCreate(ctx context.Context, engine *StateSaver, config * return "", nil, err } - // Save state as soon as the app exists so it is not orphaned if the wait or - // lifecycle management is interrupted. - - engine.SaveState(ctx, app.Name, config) + // Save with Lifecycle=nil: app exists but lifecycle has not been applied yet. + // If waitForApp or manageLifecycle is interrupted and the app reaches ACTIVE on + // its own (without a deployment), the planner on the next run sees a localDiff + // for lifecycle (nil→desired) and triggers DoUpdate → manageLifecycle → Deploy. + // Without this, OverrideChangeDesc silently skips source_code_path drift when + // the remote has no active deployment, leaving the app permanently un-deployed. + SaveStateWith(ctx, engine, app.Name, config, &config.Lifecycle, (*StateLifecycle)(nil)) remote, err := r.waitForApp(ctx, r.client, config.Name) if err != nil { From 51d00121e60d7bd1c1fdf860cacf2993cbd75420 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 9 Jun 2026 17:24:13 +0200 Subject: [PATCH 23/32] acceptance: move ETAG replacement from parent dashboard test.toml to per-test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global ETAG replacement in acceptance/bundle/resources/dashboards/test.toml replaced all 8+ digit numbers in all dashboard test outputs. This prevents local tests from using add_repl.py to record specific etag values (e.g. the bumped etag after a PATCH), which is necessary for tests that verify etag-tracking behavior across SaveState calls. Move the replacement to the three tests that actually need it for cloud compatibility (non-deterministic real etags): - detect-change: shows etag in bundle summary output - change-name: etag appears in Terraform PATCH request body - change-embed-credentials: etag appears in Terraform PATCH request body Local-only tests now see the etag replaced by the global [NUMID] rule (acceptance/test.toml, \d{8,}) instead of [ETAG]. The out.plan.direct.json files update accordingly: [ETAG] (unquoted, invalid JSON) → "[NUMID]" (quoted, valid JSON string). Co-authored-by: Isaac --- .../dashboards/change-embed-credentials/test.toml | 8 ++++++++ .../bundle/resources/dashboards/change-name/test.toml | 8 ++++++++ .../bundle/resources/dashboards/detect-change/test.toml | 9 +++++++++ 3 files changed, 25 insertions(+) create mode 100644 acceptance/bundle/resources/dashboards/change-embed-credentials/test.toml create mode 100644 acceptance/bundle/resources/dashboards/change-name/test.toml diff --git a/acceptance/bundle/resources/dashboards/change-embed-credentials/test.toml b/acceptance/bundle/resources/dashboards/change-embed-credentials/test.toml new file mode 100644 index 00000000000..5ce12029380 --- /dev/null +++ b/acceptance/bundle/resources/dashboards/change-embed-credentials/test.toml @@ -0,0 +1,8 @@ +# Etag is a long integer in the Terraform PATCH body; can be negative on cloud. +[[Repls]] +Old = "\"[-0-9]{8,}\"" +New = "[ETAG]" + +[[Repls]] +Old = "\"[0-9]{8,}\"" +New = "[ETAG]" diff --git a/acceptance/bundle/resources/dashboards/change-name/test.toml b/acceptance/bundle/resources/dashboards/change-name/test.toml new file mode 100644 index 00000000000..5ce12029380 --- /dev/null +++ b/acceptance/bundle/resources/dashboards/change-name/test.toml @@ -0,0 +1,8 @@ +# Etag is a long integer in the Terraform PATCH body; can be negative on cloud. +[[Repls]] +Old = "\"[-0-9]{8,}\"" +New = "[ETAG]" + +[[Repls]] +Old = "\"[0-9]{8,}\"" +New = "[ETAG]" diff --git a/acceptance/bundle/resources/dashboards/detect-change/test.toml b/acceptance/bundle/resources/dashboards/detect-change/test.toml index fbdfbe42eab..9cde885b8c1 100644 --- a/acceptance/bundle/resources/dashboards/detect-change/test.toml +++ b/acceptance/bundle/resources/dashboards/detect-change/test.toml @@ -17,6 +17,15 @@ MSYS_NO_PATHCONV = "1" # that no test-level retry can fix -- so opt out of the simulation here. INJECT_STALE_ON_DIRECT = "0" +# Etag is a long integer; can be negative on cloud. Replace for cloud compatibility. +[[Repls]] +Old = "\"[-0-9]{8,}\"" +New = "[ETAG]" + +[[Repls]] +Old = "\"[0-9]{8,}\"" +New = "[ETAG]" + [[Repls]] Old = "[0-9a-z]{16,}" New = "[ALPHANUMID]" From 9260b0e0804ac3b5b7920d96829e788638ded8c7 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Sun, 14 Jun 2026 09:33:54 -0700 Subject: [PATCH 24/32] direct: add StateSaver parameter to genie_space DoCreate and DoUpdate genie_space was added to main after this branch was cut, using the old DoCreate/DoUpdate signatures that lack the *StateSaver parameter added by this PR to the IResource interface. Co-authored-by: Isaac --- bundle/direct/dresources/genie_space.go | 4 ++-- bundle/direct/dresources/genie_space_test.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bundle/direct/dresources/genie_space.go b/bundle/direct/dresources/genie_space.go index 21d2c632409..54215fbbc3f 100644 --- a/bundle/direct/dresources/genie_space.go +++ b/bundle/direct/dresources/genie_space.go @@ -135,7 +135,7 @@ func isMissingGenieParentPathError(err error) bool { strings.Contains(apiErr.Message, "does not exist") } -func (r *ResourceGenieSpace) DoCreate(ctx context.Context, config *resources.GenieSpaceConfig) (string, *resources.GenieSpaceConfig, error) { +func (r *ResourceGenieSpace) DoCreate(ctx context.Context, _ *StateSaver, config *resources.GenieSpaceConfig) (string, *resources.GenieSpaceConfig, error) { serializedSpace, err := prepareGenieSpaceRequest(config) if err != nil { return "", nil, err @@ -177,7 +177,7 @@ func (r *ResourceGenieSpace) DoCreate(ctx context.Context, config *resources.Gen return createResp.SpaceId, responseToGenieSpaceConfig(createResp, serializedSpace), nil } -func (r *ResourceGenieSpace) DoUpdate(ctx context.Context, id string, config *resources.GenieSpaceConfig, _ *PlanEntry) (*resources.GenieSpaceConfig, error) { +func (r *ResourceGenieSpace) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *resources.GenieSpaceConfig, _ *PlanEntry) (*resources.GenieSpaceConfig, error) { serializedSpace, err := prepareGenieSpaceRequest(config) if err != nil { return nil, err diff --git a/bundle/direct/dresources/genie_space_test.go b/bundle/direct/dresources/genie_space_test.go index f77289083d3..bcdcdfb16ba 100644 --- a/bundle/direct/dresources/genie_space_test.go +++ b/bundle/direct/dresources/genie_space_test.go @@ -93,7 +93,7 @@ func TestGenieSpaceDoCreateRetriesWhenParentPathLooksMissing(t *testing.T) { }, nil). Once() - id, state, err := r.DoCreate(ctx, &resources.GenieSpaceConfig{ + id, state, err := r.DoCreate(ctx, nil, &resources.GenieSpaceConfig{ Title: "test genie space", Description: "test description", ParentPath: "/Workspace/test-parent", @@ -132,7 +132,7 @@ func TestGenieSpaceDoUpdateRoundTripsEtag(t *testing.T) { }, nil). Once() - state, err := r.DoUpdate(ctx, "space-id", &resources.GenieSpaceConfig{ + state, err := r.DoUpdate(ctx, nil, "space-id", &resources.GenieSpaceConfig{ Title: "new", Etag: "etag-7", }, entry) @@ -167,7 +167,7 @@ func TestGenieSpaceDoUpdateAlwaysSendsSerializedSpace(t *testing.T) { }, nil). Once() - state, err := r.DoUpdate(ctx, "space-id", &resources.GenieSpaceConfig{ + state, err := r.DoUpdate(ctx, nil, "space-id", &resources.GenieSpaceConfig{ Title: "new", SerializedSpace: "{\"converge\":\"me\"}", }, entry) From 5c982db06c8254835963847fc0d8a73f84c28f66 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Sun, 14 Jun 2026 15:25:08 -0700 Subject: [PATCH 25/32] direct: revert SaveStateWith from dashboard DoUpdate; add stale-content test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SaveStateWith(Published=false) in DoUpdate makes the stale-published-content bug permanently unrecoverable: after a PATCH+failed-POST, state has the new etag + Published=false. On the next plan, the planner sees remote.Published=true == desired=true and skips (remote_already_set), so neither a plain re-deploy nor --force can fix the stale content. Without SaveStateWith, state retains the pre-PATCH etag. The next plan detects the etag mismatch as "modified remotely" and blocks — but --force recovers it by forcing a full PATCH+POST cycle. Also cherry-picks the publish-failure-stale-content acceptance test from denik/dashboard-published-bug, which documents this pre-existing bug and confirms --force recovers it. Includes the testserver change that bumps the dashboard etag on every PATCH (matching cloud behavior), and test improvements from that branch (explicit ETAG_1/ETAG_2 labels, add_repl.py calls, etc.). Co-authored-by: Isaac --- .../output.txt | 21 ++++++++++++------- .../publish-failure-retry-on-update/script | 6 ++++-- bundle/direct/dresources/dashboard.go | 14 +++++++------ 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/output.txt b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/output.txt index 8017faf3041..08719db8ae4 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/output.txt +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/output.txt @@ -38,13 +38,20 @@ Exit code: 1 } >>> [CLI] bundle plan -o json -{ - "action": "skip", - "reason": "remote_already_set", - "old": false, - "new": true, - "remote": true -} +Warning: dashboard "dashboard1" has been modified remotely + at resources.dashboards.dashboard1 + in databricks.yml:7:7 + +This dashboard has been modified remotely since the last bundle deployment. +These modifications are untracked and will be overwritten on deploy. + +Make sure that the local dashboard definition matches what you intend to deploy +before proceeding with the deployment. + +To overwrite the remote changes with your local version, use --force. +The remote modifications will be lost. + +null >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/script b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/script index 27471ef05f6..d72e26efffa 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/script +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/script @@ -28,6 +28,8 @@ errcode trace $CLI bundle deploy # (POST /api/2.0/lakeview/dashboards): the existing dashboard was updated in place. print_requests.py //lakeview/dashboards -# Plan shows published old=false: state was saved with published=false (and the bumped -# etag) before the failed publish, so the next deploy knows publishing is still pending. +# Plan shows "modified remotely": state still holds the pre-PATCH etag; the PATCH bumped +# the remote etag, so the next plan sees an etag mismatch and blocks with a warning. +# The published field is not shown as a pending change (null) because the "modified +# remotely" guard takes precedence. trace $CLI bundle plan -o json | jq '.plan["resources.dashboards.dashboard1"].changes.published' diff --git a/bundle/direct/dresources/dashboard.go b/bundle/direct/dresources/dashboard.go index 99cdd2c904a..92316f20f05 100644 --- a/bundle/direct/dresources/dashboard.go +++ b/bundle/direct/dresources/dashboard.go @@ -355,7 +355,7 @@ func (r *ResourceDashboard) DoCreate(ctx context.Context, engine *StateSaver, co return createResp.DashboardId, responseToState(createResp, publishResp, dashboard.SerializedDashboard, config.Published), nil } -func (r *ResourceDashboard) DoUpdate(ctx context.Context, engine *StateSaver, id string, config *DashboardState, _ *PlanEntry) (*DashboardState, error) { +func (r *ResourceDashboard) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *DashboardState, _ *PlanEntry) (*DashboardState, error) { dashboard, err := prepareDashboardRequest(config) if err != nil { return nil, err @@ -375,12 +375,14 @@ func (r *ResourceDashboard) DoUpdate(ctx context.Context, engine *StateSaver, id return nil, err } - // Persist the new etag with Published=false before publishing. Update() bumps the - // etag on the server; if a subsequent publish fails, saving here keeps the etag in - // sync (a stale etag would make the next Update fail with a conflict) and records - // published=false so the planner re-publishes on the next deploy. + // Persist the etag in state. + // Note: we intentionally do NOT save state here with Published=false before + // publishing. If we did, and publish fails, the next plan would see + // remote.Published=true == desired=true and skip (remote_already_set), making + // the stale published content permanently unrecoverable, even with --force. + // By not saving, state retains the pre-update etag; the next plan detects the + // etag mismatch as "modified remotely" and blocks — recoverable with --force. config.Etag = updateResp.Etag - SaveStateWith(ctx, engine, id, config, &config.Published, false) var publishResp *dashboards.PublishedDashboard // Note, today config.Published is always true (we do not have this field in input config). From dabe0cdf7af71bdad0a0db5b63e71fa72ee4c905 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Thu, 25 Jun 2026 10:44:38 +0200 Subject: [PATCH 26/32] direct: add StateSaver parameter to postgres_database and postgres_role DoCreate/DoUpdate Co-authored-by: Denis Bilenko --- bundle/direct/dresources/postgres_database.go | 4 ++-- bundle/direct/dresources/postgres_role.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bundle/direct/dresources/postgres_database.go b/bundle/direct/dresources/postgres_database.go index adfd7326eec..ffc84b10290 100644 --- a/bundle/direct/dresources/postgres_database.go +++ b/bundle/direct/dresources/postgres_database.go @@ -97,7 +97,7 @@ func (r *ResourcePostgresDatabase) DoRead(ctx context.Context, id string) (*Post return makePostgresDatabaseRemote(database), nil } -func (r *ResourcePostgresDatabase) DoCreate(ctx context.Context, config *PostgresDatabaseState) (string, *PostgresDatabaseRemote, error) { +func (r *ResourcePostgresDatabase) DoCreate(ctx context.Context, _ *StateSaver, config *PostgresDatabaseState) (string, *PostgresDatabaseRemote, error) { waiter, err := r.client.Postgres.CreateDatabase(ctx, postgres.CreateDatabaseRequest{ DatabaseId: config.DatabaseId, Parent: config.Parent, @@ -130,7 +130,7 @@ func (r *ResourcePostgresDatabase) DoCreate(ctx context.Context, config *Postgre return remote.Name, remote, nil } -func (r *ResourcePostgresDatabase) DoUpdate(ctx context.Context, id string, config *PostgresDatabaseState, entry *PlanEntry) (*PostgresDatabaseRemote, error) { +func (r *ResourcePostgresDatabase) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *PostgresDatabaseState, entry *PlanEntry) (*PostgresDatabaseRemote, error) { // Build update mask from fields that have action="update" in the changes map. // This excludes immutable fields and fields that haven't changed. // Prefix with "spec." because the API expects paths relative to the Database object, diff --git a/bundle/direct/dresources/postgres_role.go b/bundle/direct/dresources/postgres_role.go index 99f847dc56d..b4f4e730ca7 100644 --- a/bundle/direct/dresources/postgres_role.go +++ b/bundle/direct/dresources/postgres_role.go @@ -122,7 +122,7 @@ func (r *ResourcePostgresRole) DoRead(ctx context.Context, id string) (*Postgres return makePostgresRoleRemote(role), nil } -func (r *ResourcePostgresRole) DoCreate(ctx context.Context, config *PostgresRoleState) (string, *PostgresRoleRemote, error) { +func (r *ResourcePostgresRole) DoCreate(ctx context.Context, _ *StateSaver, config *PostgresRoleState) (string, *PostgresRoleRemote, error) { waiter, err := r.client.Postgres.CreateRole(ctx, postgres.CreateRoleRequest{ RoleId: config.RoleId, Parent: config.Parent, @@ -154,7 +154,7 @@ func (r *ResourcePostgresRole) DoCreate(ctx context.Context, config *PostgresRol return remote.Name, remote, nil } -func (r *ResourcePostgresRole) DoUpdate(ctx context.Context, id string, config *PostgresRoleState, entry *PlanEntry) (*PostgresRoleRemote, error) { +func (r *ResourcePostgresRole) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *PostgresRoleState, entry *PlanEntry) (*PostgresRoleRemote, error) { // Build update mask from fields that have action="update" in the changes map. // Prefix with "spec." because the API expects paths relative to the Role // object, not relative to our flattened state type. From 3ba6c4f86708217651c8be03fc8df2ba9d6ec106 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 29 Jul 2026 11:58:21 +0200 Subject: [PATCH 27/32] direct: adapt instance_pool/job_run DoCreate/DoUpdate to StateSaver signature instance_pool and job_run were added on main after this branch was cut, so their DoCreate/DoUpdate still had the pre-StateSaver signature and failed the IResource interface check. Add the *StateSaver parameter (unused). Also fix the app retry tests and dashboard publish-failure tests for the post-rebase testserver: the apps testserver now keeps DELETING apps visible (cloud-realistic), and the parent dashboards test.toml injects eventual- consistency staleness on direct; opt the publish-failure/retry tests out of that injection since they drive an explicit read-back. Co-authored-by: Isaac --- .../out.dashboardrequests.direct.txt | 7 ++ .../publish-failure-retry-on-update/test.toml | 7 ++ .../publish-failure-retry/test.toml | 7 ++ bundle/direct/dresources/app_test.go | 102 ++++++++++++++---- bundle/direct/dresources/cluster.go | 2 +- bundle/direct/dresources/instance_pool.go | 4 +- bundle/direct/dresources/job_run.go | 2 +- 7 files changed, 108 insertions(+), 23 deletions(-) diff --git a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.dashboardrequests.direct.txt b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.dashboardrequests.direct.txt index 3e94771bdac..c92a978b536 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.dashboardrequests.direct.txt +++ b/acceptance/bundle/resources/dashboards/publish-failure-cleans-up-dashboard/out.dashboardrequests.direct.txt @@ -1,3 +1,10 @@ +{ + "method": "POST", + "path": "/api/2.0/workspace/mkdirs", + "body": { + "path": "/Workspace/Users/[USERNAME]/.bundle/publish-failure-cleans-up-dashboard/default/state" + } +} { "method": "POST", "path": "/api/2.0/workspace/mkdirs", diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/test.toml b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/test.toml index 5272a2289ad..0db7c5b4844 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/test.toml +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/test.toml @@ -7,6 +7,13 @@ RecordRequests = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] +[Env] +# This test drives an explicit publish-failure/retry sequence and reads the +# dashboard back with `bundle plan`. The inherited eventual-consistency simulation +# would make that read observe a stale 404 and mask the behavior under test, so +# opt out of it here (same reason as detect-change). +INJECT_STALE_ON_DIRECT = "0" + # Dashboard published URLs use ?o= (local testserver) or ?w= (cloud) for the workspace/org ID. [[Repls]] Old = '\?[ow]=\d+' diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml b/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml index 177fe084644..ee3a81bc7bf 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry/test.toml @@ -7,6 +7,13 @@ RecordRequests = true [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] +[Env] +# This test drives an explicit publish-failure/retry sequence and reads the +# dashboard back with `bundle plan`. The inherited eventual-consistency simulation +# would make that read observe a stale 404 and mask the behavior under test, so +# opt out of it here (same reason as detect-change). +INJECT_STALE_ON_DIRECT = "0" + # Dashboard published URLs use ?o= (local testserver) or ?w= (cloud) for the workspace/org ID. [[Repls]] Old = '\?[ow]=\d+' diff --git a/bundle/direct/dresources/app_test.go b/bundle/direct/dresources/app_test.go index 72185bfaa7f..f7662dec46f 100644 --- a/bundle/direct/dresources/app_test.go +++ b/bundle/direct/dresources/app_test.go @@ -17,6 +17,46 @@ import ( // an app already exists but is in DELETING state. func TestAppDoCreate_RetriesWhenAppIsDeleting(t *testing.T) { server := testserver.New(t) + + createCallCount := 0 + getCallCount := 0 + + server.Handle("POST", "/api/2.0/apps", func(req testserver.Request) any { + createCallCount++ + if createCallCount == 1 { + return testserver.Response{ + StatusCode: 409, + Body: map[string]string{ + "error_code": "RESOURCE_ALREADY_EXISTS", + "message": "An app with the same name already exists.", + }, + } + } + return apps.App{ + Name: "test-app", + ComputeStatus: &apps.ComputeStatus{ + State: apps.ComputeStateActive, + }, + } + }) + + // GET serves two phases: the retry-check (before the successful create, + // reports DELETING so DoCreate retries) and the post-create waitForApp poll + // (after the create succeeds, reports ACTIVE so the wait terminates). + server.Handle("GET", "/api/2.0/apps/{name}", func(req testserver.Request) any { + getCallCount++ + state := apps.ComputeStateActive + if createCallCount < 2 { + state = apps.ComputeStateDeleting + } + return apps.App{ + Name: req.Vars["name"], + ComputeStatus: &apps.ComputeStatus{ + State: state, + }, + } + }) + testserver.AddDefaultHandlers(server) client, err := databricks.NewWorkspaceClient(&databricks.Config{ @@ -25,21 +65,15 @@ func TestAppDoCreate_RetriesWhenAppIsDeleting(t *testing.T) { }) require.NoError(t, err) - ctx := t.Context() - - // Create then delete an app to put it in DELETING state. - // The testserver's DELETE is asynchronous: it sets DELETING rather than - // removing immediately, so the retry create will find the app in that state. - _, err = client.Apps.Create(ctx, apps.CreateAppRequest{App: apps.App{Name: "test-app"}}) - require.NoError(t, err) - _, err = client.Apps.DeleteByName(ctx, "test-app") - require.NoError(t, err) - r := (&ResourceApp{}).New(client) + ctx := t.Context() name, _, err := r.DoCreate(ctx, NewNopStateSaver(reflect.TypeFor[*AppState]()), &AppState{App: apps.App{Name: "test-app"}}) require.NoError(t, err) assert.Equal(t, "test-app", name) + assert.Equal(t, 2, createCallCount, "expected Create to be called twice (1 retry)") + // One GET during the retry check (DELETING) and one during waitForApp (ACTIVE). + assert.Equal(t, 2, getCallCount, "expected Get to be called for the retry check and the post-create wait") } // TestAppDoCreate_RetriesWhenGetReturnsNotFound verifies that DoCreate retries @@ -47,20 +81,47 @@ func TestAppDoCreate_RetriesWhenAppIsDeleting(t *testing.T) { func TestAppDoCreate_RetriesWhenGetReturnsNotFound(t *testing.T) { server := testserver.New(t) - // Simulate a race: the app existed when Create was called (returns 409) but - // was deleted before the existence check (GET returns 404). The first POST - // returns 409 without storing anything so the standard GET handler returns - // 404 naturally, and the retry POST creates the app normally. - rejectedOnce := false + createCallCount := 0 + getCallCount := 0 + server.Handle("POST", "/api/2.0/apps", func(req testserver.Request) any { - if !rejectedOnce { - rejectedOnce = true + createCallCount++ + if createCallCount == 1 { return testserver.Response{ StatusCode: 409, - Body: map[string]string{"error_code": "RESOURCE_ALREADY_EXISTS", "message": "An app with the same name already exists."}, + Body: map[string]string{ + "error_code": "RESOURCE_ALREADY_EXISTS", + "message": "An app with the same name already exists.", + }, } } - return req.Workspace.AppsUpsert(req, "") + return apps.App{ + Name: "test-app", + ComputeStatus: &apps.ComputeStatus{ + State: apps.ComputeStateActive, + }, + } + }) + + // GET returns 404 during the retry check (app was deleted between the create + // and the existence check), then ACTIVE for the post-create waitForApp poll. + server.Handle("GET", "/api/2.0/apps/{name}", func(req testserver.Request) any { + getCallCount++ + if createCallCount < 2 { + return testserver.Response{ + StatusCode: 404, + Body: map[string]string{ + "error_code": "RESOURCE_DOES_NOT_EXIST", + "message": "App not found.", + }, + } + } + return apps.App{ + Name: req.Vars["name"], + ComputeStatus: &apps.ComputeStatus{ + State: apps.ComputeStateActive, + }, + } }) testserver.AddDefaultHandlers(server) @@ -77,6 +138,9 @@ func TestAppDoCreate_RetriesWhenGetReturnsNotFound(t *testing.T) { require.NoError(t, err) assert.Equal(t, "test-app", name) + assert.Equal(t, 2, createCallCount, "expected Create to be called twice") + // One GET during the retry check (404) and one during waitForApp (ACTIVE). + assert.Equal(t, 2, getCallCount, "expected Get to be called for the retry check and the post-create wait") } func TestAppDoUpdate_UpdateMaskHasAllFields(t *testing.T) { diff --git a/bundle/direct/dresources/cluster.go b/bundle/direct/dresources/cluster.go index fccf23797d1..12e1cf32f6f 100644 --- a/bundle/direct/dresources/cluster.go +++ b/bundle/direct/dresources/cluster.go @@ -272,7 +272,7 @@ func (r *ResourceCluster) DoResize(ctx context.Context, id string, config *Clust } // Cluster is not running; fall back to the full clusters/edit path. - // DoUpdate ignores its Engine argument, so passing nil here is safe. + // DoUpdate ignores its StateSaver argument, so passing nil here is safe. log.Debugf(ctx, "cluster %s: resize returned INVALID_STATE (%s), falling back to edit", id, err) _, err = r.DoUpdate(ctx, nil, id, config, entry) return err diff --git a/bundle/direct/dresources/instance_pool.go b/bundle/direct/dresources/instance_pool.go index 37a1aeab447..5ebb1aff89f 100644 --- a/bundle/direct/dresources/instance_pool.go +++ b/bundle/direct/dresources/instance_pool.go @@ -49,7 +49,7 @@ func (r *ResourceInstancePool) DoRead(ctx context.Context, id string) (*compute. return r.client.InstancePools.GetByInstancePoolId(ctx, id) } -func (r *ResourceInstancePool) DoCreate(ctx context.Context, config *compute.CreateInstancePool) (string, *compute.GetInstancePool, error) { +func (r *ResourceInstancePool) DoCreate(ctx context.Context, _ *StateSaver, config *compute.CreateInstancePool) (string, *compute.GetInstancePool, error) { resp, err := r.client.InstancePools.Create(ctx, *config) if err != nil { return "", nil, err @@ -57,7 +57,7 @@ func (r *ResourceInstancePool) DoCreate(ctx context.Context, config *compute.Cre return resp.InstancePoolId, nil, nil } -func (r *ResourceInstancePool) DoUpdate(ctx context.Context, id string, config *compute.CreateInstancePool, _ *PlanEntry) (*compute.GetInstancePool, error) { +func (r *ResourceInstancePool) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *compute.CreateInstancePool, _ *PlanEntry) (*compute.GetInstancePool, error) { return nil, r.client.InstancePools.Edit(ctx, compute.EditInstancePool{ InstancePoolId: id, InstancePoolName: config.InstancePoolName, diff --git a/bundle/direct/dresources/job_run.go b/bundle/direct/dresources/job_run.go index 0a2ae0ea6af..429b581eead 100644 --- a/bundle/direct/dresources/job_run.go +++ b/bundle/direct/dresources/job_run.go @@ -128,7 +128,7 @@ func (*ResourceJobRun) RemapState(remote *JobRunRemote) *JobRunState { return &JobRunState{RunNow: remote.RunNow} } -func (r *ResourceJobRun) DoCreate(ctx context.Context, config *JobRunState) (string, *JobRunRemote, error) { +func (r *ResourceJobRun) DoCreate(ctx context.Context, _ *StateSaver, config *JobRunState) (string, *JobRunRemote, error) { // RunNow returns only the new run id, so we return a nil remote and let the // framework read it back via DoRead. wait, err := r.client.Jobs.RunNow(ctx, config.RunNow) From fb2cbd5f9a63f0f4400bb4d70752118ed70ab57c Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 29 Jul 2026 12:00:51 +0200 Subject: [PATCH 28/32] direct: fix README reference to renamed StateSaver type The Engine->StateSaver rename missed the async-APIs section of the dresources README, which still called the second DoCreate/DoUpdate argument a *Engine. Co-authored-by: Isaac --- bundle/direct/dresources/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundle/direct/dresources/README.md b/bundle/direct/dresources/README.md index 37056d7bd23..bb1cd2b7b0c 100644 --- a/bundle/direct/dresources/README.md +++ b/bundle/direct/dresources/README.md @@ -36,7 +36,7 @@ If a resource has fields that must not be sent in updates (deploy-only, lifecycl ## Async APIs -For resources whose create or update is asynchronous, poll inline inside `DoCreate`/`DoUpdate` after the initial API call. To prevent orphaning if deployment is interrupted during a long wait, call `engine.SaveState(ctx, id, config)` immediately after the resource is created and before any waiting. The framework provides a `*Engine` as the second argument to both methods. +For resources whose create or update is asynchronous, poll inline inside `DoCreate`/`DoUpdate` after the initial API call. To prevent orphaning if deployment is interrupted during a long wait, call `engine.SaveState(ctx, id, config)` immediately after the resource is created and before any waiting. The framework provides a `*StateSaver` as the second argument to both methods. ## Slice ordering: KeyedSlices From 4c2736db9290708e8b2391b8df46d2c9c5661242 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 29 Jul 2026 14:52:57 +0200 Subject: [PATCH 29/32] direct: drop RedactSensitiveFields from state save paths Revert the redaction added during the rebase conflict resolution: it depended on structwalk.RedactSensitiveFields, which is being removed. State is marshaled with plain json.Marshal in both SaveState and StateSaver.SaveState, as before. Co-authored-by: Isaac --- bundle/direct/dresources/state_saver.go | 11 +---------- bundle/direct/dstate/state.go | 11 ++--------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/bundle/direct/dresources/state_saver.go b/bundle/direct/dresources/state_saver.go index 5cd3e489643..3d4f0656a9c 100644 --- a/bundle/direct/dresources/state_saver.go +++ b/bundle/direct/dresources/state_saver.go @@ -7,10 +7,8 @@ import ( "fmt" "reflect" - "github.com/databricks/cli/libs/dyn" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/logdiag" - "github.com/databricks/cli/libs/structs/structwalk" ) // StateSaver provides state persistence to resource implementations. @@ -65,14 +63,7 @@ func (e *StateSaver) SaveState(ctx context.Context, id string, x any) { if xt != e.stateType { panic(fmt.Sprintf("SaveState: type mismatch: expected %v, got %v", e.stateType, xt)) } - // Redact sensitive fields before persisting: secrets must not appear on disk - // in plaintext. The saveFunc hands these bytes to SaveStateJSON, which writes - // them as-is, so redaction must happen here rather than in the state layer. - b, err := structwalk.RedactSensitiveFields(x, dyn.SensitiveValueRedacted) - if err != nil { - logdiag.LogError(ctx, err) - return - } + b, _ := json.Marshal(x) if bytes.Equal(e.lastSaved, b) { log.Debugf(ctx, "SaveState: %s id=%s: skipping, state unchanged", e.resourceKey, id) return diff --git a/bundle/direct/dstate/state.go b/bundle/direct/dstate/state.go index 50d476c6905..dd11238caad 100644 --- a/bundle/direct/dstate/state.go +++ b/bundle/direct/dstate/state.go @@ -16,9 +16,7 @@ import ( "github.com/databricks/cli/bundle/deployplan" "github.com/databricks/cli/bundle/statemgmt/resourcestate" "github.com/databricks/cli/internal/build" - "github.com/databricks/cli/libs/dyn" "github.com/databricks/cli/libs/log" - "github.com/databricks/cli/libs/structs/structwalk" "github.com/google/uuid" ) @@ -121,11 +119,8 @@ func NewDatabase(lineage string, serial int) Database { } func (db *DeploymentState) SaveState(key, newID string, state any, dependsOn []deployplan.DependsOnEntry) error { - // Redact sensitive fields before persisting: secrets must not appear on disk - // in plaintext. The original struct is not modified; the plan uses the - // unredacted in-memory value for API calls. RedactSensitiveFields marshals - // without indentation, so every WAL entry remains on a single line. - b, err := structwalk.RedactSensitiveFields(state, dyn.SensitiveValueRedacted) + // don't indent so that every WAL entry remains on a single line + b, err := json.Marshal(state) if err != nil { return err } @@ -134,8 +129,6 @@ func (db *DeploymentState) SaveState(key, newID string, state any, dependsOn []d // SaveStateJSON saves pre-marshaled JSON state, avoiding a redundant marshal when // the caller already holds the serialized bytes (e.g. the StateSaver dedup path). -// Callers are responsible for redacting sensitive fields before marshaling; the -// bytes are persisted as-is. func (db *DeploymentState) SaveStateJSON(key, newID string, state json.RawMessage, dependsOn []deployplan.DependsOnEntry) error { db.AssertOpenedForWrite() db.mu.Lock() From 51cee66925c811536e7f59473ce419e96a090ce4 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 29 Jul 2026 15:56:53 +0200 Subject: [PATCH 30/32] acceptance/dashboards: drop wide etag regexes, rely on precise ACC_REPLS The scripts already capture each etag by value into ACC_REPLS (ETAG_1, ETAG_2, ETAG), so the catch-all "\"[-0-9]{8,}\"" / "\"[0-9]{8,}\"" [[Repls]] were redundant and risked masking unrelated quoted long integers. Remove them; change-name and change-embed-credentials test.toml held nothing else, so drop those files entirely. Co-authored-by: Isaac --- .../dashboards/change-embed-credentials/test.toml | 8 -------- .../bundle/resources/dashboards/change-name/test.toml | 8 -------- .../bundle/resources/dashboards/detect-change/test.toml | 9 --------- 3 files changed, 25 deletions(-) delete mode 100644 acceptance/bundle/resources/dashboards/change-embed-credentials/test.toml delete mode 100644 acceptance/bundle/resources/dashboards/change-name/test.toml diff --git a/acceptance/bundle/resources/dashboards/change-embed-credentials/test.toml b/acceptance/bundle/resources/dashboards/change-embed-credentials/test.toml deleted file mode 100644 index 5ce12029380..00000000000 --- a/acceptance/bundle/resources/dashboards/change-embed-credentials/test.toml +++ /dev/null @@ -1,8 +0,0 @@ -# Etag is a long integer in the Terraform PATCH body; can be negative on cloud. -[[Repls]] -Old = "\"[-0-9]{8,}\"" -New = "[ETAG]" - -[[Repls]] -Old = "\"[0-9]{8,}\"" -New = "[ETAG]" diff --git a/acceptance/bundle/resources/dashboards/change-name/test.toml b/acceptance/bundle/resources/dashboards/change-name/test.toml deleted file mode 100644 index 5ce12029380..00000000000 --- a/acceptance/bundle/resources/dashboards/change-name/test.toml +++ /dev/null @@ -1,8 +0,0 @@ -# Etag is a long integer in the Terraform PATCH body; can be negative on cloud. -[[Repls]] -Old = "\"[-0-9]{8,}\"" -New = "[ETAG]" - -[[Repls]] -Old = "\"[0-9]{8,}\"" -New = "[ETAG]" diff --git a/acceptance/bundle/resources/dashboards/detect-change/test.toml b/acceptance/bundle/resources/dashboards/detect-change/test.toml index 9cde885b8c1..fbdfbe42eab 100644 --- a/acceptance/bundle/resources/dashboards/detect-change/test.toml +++ b/acceptance/bundle/resources/dashboards/detect-change/test.toml @@ -17,15 +17,6 @@ MSYS_NO_PATHCONV = "1" # that no test-level retry can fix -- so opt out of the simulation here. INJECT_STALE_ON_DIRECT = "0" -# Etag is a long integer; can be negative on cloud. Replace for cloud compatibility. -[[Repls]] -Old = "\"[-0-9]{8,}\"" -New = "[ETAG]" - -[[Repls]] -Old = "\"[0-9]{8,}\"" -New = "[ETAG]" - [[Repls]] Old = "[0-9a-z]{16,}" New = "[ALPHANUMID]" From 8c1c43a287868835ddcbfdc24f708745709eb9f6 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 5 Aug 2026 14:34:45 +0200 Subject: [PATCH 31/32] direct: save dashboard state before publishing in DoUpdate Now that DoRead derives published from the publish/update timestamps (#6119), the reason DoUpdate avoided saving state before the publish is gone: a stale publish reports remote published=false, so the remote_already_set skip that would have stranded it can no longer happen. Save the post-update etag and published=false before publishing. A failed publish is now recoverable with a plain deploy: the next plan sees desired published=true against a remote reported as false and republishes. Saving the post-update etag also keeps state in sync with remote, so CheckDashboardsModifiedRemotely no longer misreports this as an out-of-band edit and --force is no longer required. Drops the Badness marker from publish-failure-stale-content and updates it to assert recovery on a plain re-deploy. Co-authored-by: Isaac --- .../output.txt | 20 +++---- .../publish-failure-stale-content/output.txt | 52 ++----------------- .../publish-failure-stale-content/script | 23 +++----- .../publish-failure-stale-content/test.toml | 1 - bundle/direct/dresources/dashboard.go | 16 +++--- 5 files changed, 27 insertions(+), 85 deletions(-) diff --git a/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/output.txt b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/output.txt index 08719db8ae4..4759e7c3dcf 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/output.txt +++ b/acceptance/bundle/resources/dashboards/publish-failure-retry-on-update/output.txt @@ -38,20 +38,12 @@ Exit code: 1 } >>> [CLI] bundle plan -o json -Warning: dashboard "dashboard1" has been modified remotely - at resources.dashboards.dashboard1 - in databricks.yml:7:7 - -This dashboard has been modified remotely since the last bundle deployment. -These modifications are untracked and will be overwritten on deploy. - -Make sure that the local dashboard definition matches what you intend to deploy -before proceeding with the deployment. - -To overwrite the remote changes with your local version, use --force. -The remote modifications will be lost. - -null +{ + "action": "update", + "old": false, + "new": true, + "remote": false +} >>> [CLI] bundle destroy --auto-approve The following resources will be deleted: diff --git a/acceptance/bundle/resources/dashboards/publish-failure-stale-content/output.txt b/acceptance/bundle/resources/dashboards/publish-failure-stale-content/output.txt index a88b5057bc6..096f591df97 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-stale-content/output.txt +++ b/acceptance/bundle/resources/dashboards/publish-failure-stale-content/output.txt @@ -69,62 +69,20 @@ Exit code: 1 } >>> [CLI] bundle plan -o json -Warning: dashboard "dashboard1" has been modified remotely - at resources.dashboards.dashboard1 - in databricks.yml:7:7 - -This dashboard has been modified remotely since the last bundle deployment. -These modifications are untracked and will be overwritten on deploy. - -Make sure that the local dashboard definition matches what you intend to deploy -before proceeding with the deployment. - -To overwrite the remote changes with your local version, use --force. -The remote modifications will be lost. - json.plan.resources.dashboards.dashboard1.new_state.value.published = true; json.plan.resources.dashboards.dashboard1.remote_state.etag = "[ETAG_2]"; json.plan.resources.dashboards.dashboard1.remote_state.published = false; -json.plan.resources.dashboards.dashboard1.changes.etag.action = "update"; -json.plan.resources.dashboards.dashboard1.changes.etag.old = "[ETAG_1]"; +json.plan.resources.dashboards.dashboard1.changes.etag.action = "skip"; +json.plan.resources.dashboards.dashboard1.changes.etag.reason = "custom"; +json.plan.resources.dashboards.dashboard1.changes.etag.old = "[ETAG_2]"; json.plan.resources.dashboards.dashboard1.changes.etag.remote = "[ETAG_2]"; json.plan.resources.dashboards.dashboard1.changes.published.action = "update"; -json.plan.resources.dashboards.dashboard1.changes.published.old = true; +json.plan.resources.dashboards.dashboard1.changes.published.old = false; json.plan.resources.dashboards.dashboard1.changes.published.new = true; json.plan.resources.dashboards.dashboard1.changes.published.remote = false; json.plan.resources.dashboards.dashboard1.changes.serialized_dashboard.reason = "etag_based"; >>> [CLI] bundle deploy -Error: dashboard "dashboard1" has been modified remotely - at resources.dashboards.dashboard1 - in databricks.yml:7:7 - -This dashboard has been modified remotely since the last bundle deployment. -These modifications are untracked and will be overwritten on deploy. - -Make sure that the local dashboard definition matches what you intend to deploy -before proceeding with the deployment. - -To overwrite the remote changes with your local version, use --force. -The remote modifications will be lost. - - -Exit code: 1 - ->>> print_requests.py //lakeview/dashboards - ->>> [CLI] lakeview get [DASHBOARD1_ID] -{ - "display_name": "my dashboard renamed", - "etag": "[ETAG_2]" -} - ->>> [CLI] lakeview get-published [DASHBOARD1_ID] -{ - "display_name": "my dashboard" -} - ->>> [CLI] bundle deploy --force Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-publish-failure-stale-content/default/files... Deploying resources... Updating deployment state... @@ -153,7 +111,7 @@ Deployment complete! >>> [CLI] lakeview get [DASHBOARD1_ID] { "display_name": "my dashboard renamed", - "etag": "[ETAG_3]" + "etag": "[ETAG_2]" } >>> [CLI] lakeview get-published [DASHBOARD1_ID] diff --git a/acceptance/bundle/resources/dashboards/publish-failure-stale-content/script b/acceptance/bundle/resources/dashboards/publish-failure-stale-content/script index 2fb37909fa7..765929291f8 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-stale-content/script +++ b/acceptance/bundle/resources/dashboards/publish-failure-stale-content/script @@ -25,8 +25,9 @@ fault.py "POST /api/2.0/lakeview/dashboards/*" 400 0 1 # Rename the dashboard to trigger an Update. update_file.py databricks.yml "my dashboard" "my dashboard renamed" -# Deploy: PATCH succeeds (bumping the remote etag) but publish fails. -# SaveState is only called on success, so state retains the pre-PATCH etag. +# Deploy: PATCH succeeds (bumping the remote etag) but publish fails. State is saved +# before the publish with the post-PATCH etag and published=false, so the stale-publish +# situation is recorded honestly. errcode trace $CLI bundle deploy trace print_requests.py //lakeview/dashboards # The PATCH bumped the remote etag to ETAG_2; retry until it is visible (eventual consistency). @@ -34,23 +35,15 @@ ETAG_2=$(retry --until-not "$ETAG_1" $CLI lakeview get $DASHBOARD_ID | jq -r '.e add_repl.py "$ETAG_2" ETAG_2 trace $CLI lakeview get $DASHBOARD_ID | jq '{display_name, etag}' trace $CLI lakeview get-published $DASHBOARD_ID | jq '{display_name}' +# The stored etag matches remote, so there is no "modified remotely" warning. DoRead +# reports published=false (revision_create_time < update_time), so a republish is owed. trace $CLI bundle plan -o json | gron.py | grep -E "etag|published" -# Bug: re-running deploy fails with "modified remotely" because the stored etag -# (pre-PATCH) no longer matches the remote etag (bumped by the PATCH above). -# No API writes are attempted — the stale published content is stuck. -errcode trace $CLI bundle deploy +# A plain re-deploy (no --force) recovers: it republishes and fixes the stale content. +trace $CLI bundle deploy trace print_requests.py //lakeview/dashboards trace $CLI lakeview get $DASHBOARD_ID | jq '{display_name, etag}' trace $CLI lakeview get-published $DASHBOARD_ID | jq '{display_name}' -# --force bypasses the etag check and forces a full re-deploy (PATCH + POST /published), -# which fixes the stale published content. -trace $CLI bundle deploy --force -trace print_requests.py //lakeview/dashboards -# --force did another PATCH; retry until the new etag is visible (eventual consistency). -ETAG_3=$(retry --until-not "$ETAG_2" $CLI lakeview get $DASHBOARD_ID | jq -r '.etag') -add_repl.py "$ETAG_3" ETAG_3 -trace $CLI lakeview get $DASHBOARD_ID | jq '{display_name, etag}' -trace $CLI lakeview get-published $DASHBOARD_ID | jq '{display_name}' +# The follow-up plan is a clean no-op: the published revision now matches the draft. trace $CLI bundle plan diff --git a/acceptance/bundle/resources/dashboards/publish-failure-stale-content/test.toml b/acceptance/bundle/resources/dashboards/publish-failure-stale-content/test.toml index ee9d6ba03ae..41a8ad88f69 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-stale-content/test.toml +++ b/acceptance/bundle/resources/dashboards/publish-failure-stale-content/test.toml @@ -1,4 +1,3 @@ -Badness = "after publish failure, re-deploy fails with 'modified remotely' instead of republishing; --force is required as a workaround" Cloud = false Local = true RecordRequests = true diff --git a/bundle/direct/dresources/dashboard.go b/bundle/direct/dresources/dashboard.go index 92316f20f05..7f9f443a87e 100644 --- a/bundle/direct/dresources/dashboard.go +++ b/bundle/direct/dresources/dashboard.go @@ -355,7 +355,7 @@ func (r *ResourceDashboard) DoCreate(ctx context.Context, engine *StateSaver, co return createResp.DashboardId, responseToState(createResp, publishResp, dashboard.SerializedDashboard, config.Published), nil } -func (r *ResourceDashboard) DoUpdate(ctx context.Context, _ *StateSaver, id string, config *DashboardState, _ *PlanEntry) (*DashboardState, error) { +func (r *ResourceDashboard) DoUpdate(ctx context.Context, engine *StateSaver, id string, config *DashboardState, _ *PlanEntry) (*DashboardState, error) { dashboard, err := prepareDashboardRequest(config) if err != nil { return nil, err @@ -375,14 +375,14 @@ func (r *ResourceDashboard) DoUpdate(ctx context.Context, _ *StateSaver, id stri return nil, err } - // Persist the etag in state. - // Note: we intentionally do NOT save state here with Published=false before - // publishing. If we did, and publish fails, the next plan would see - // remote.Published=true == desired=true and skip (remote_already_set), making - // the stale published content permanently unrecoverable, even with --force. - // By not saving, state retains the pre-update etag; the next plan detects the - // etag mismatch as "modified remotely" and blocks — recoverable with --force. + // Persist the new etag and Published=false before publishing: the update bumped the + // draft, so the previously-published content is now stale. If the publish below + // fails, the next plan compares the desired Published=true against a remote that + // DoRead reports as false (revision_create_time < update_time) and republishes on a + // plain deploy. Saving the post-update etag also keeps state in sync with remote, so + // CheckDashboardsModifiedRemotely does not misreport this as an out-of-band edit. config.Etag = updateResp.Etag + SaveStateWith(ctx, engine, id, config, &config.Published, false) var publishResp *dashboards.PublishedDashboard // Note, today config.Published is always true (we do not have this field in input config). From 060ad915ca853ebb96065e66bc3fdd87ba5b2b91 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 5 Aug 2026 14:58:55 +0200 Subject: [PATCH 32/32] acceptance/postgres: update goldens for uncorrupted duration state Dropping RedactSensitiveFields from the state save path also fixes the values persisted for protobuf Duration fields. RedactSensitiveFields deep-clones the struct field-by-field via reflection, which loses durationpb.Duration's internal state, so its custom marshaler emitted the zero value: state recorded history_retention_duration and suspend_timeout_duration as "0s" instead of the real "604800s"/"300s". Plain json.Marshal preserves them. Only the saved "old" values in these plan goldens change; there is no behavior change beyond state now matching what the server returned. Co-authored-by: Isaac --- .../update_autoscaling/out.plan.no_change.direct.json | 2 +- .../update_autoscaling/out.plan.restore.direct.json | 2 +- .../update_autoscaling/out.plan.update.direct.json | 2 +- .../update_display_name/out.plan.no_change.direct.json | 4 ++-- .../update_display_name/out.plan.restore.direct.json | 4 ++-- .../update_display_name/out.plan.update.direct.json | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/acceptance/bundle/resources/postgres_endpoints/update_autoscaling/out.plan.no_change.direct.json b/acceptance/bundle/resources/postgres_endpoints/update_autoscaling/out.plan.no_change.direct.json index 3365c4c9d10..7320ab94146 100644 --- a/acceptance/bundle/resources/postgres_endpoints/update_autoscaling/out.plan.no_change.direct.json +++ b/acceptance/bundle/resources/postgres_endpoints/update_autoscaling/out.plan.no_change.direct.json @@ -29,7 +29,7 @@ "suspend_timeout_duration": { "action": "skip", "reason": "empty", - "old": "0s", + "old": "300s", "new": "300s" } } diff --git a/acceptance/bundle/resources/postgres_endpoints/update_autoscaling/out.plan.restore.direct.json b/acceptance/bundle/resources/postgres_endpoints/update_autoscaling/out.plan.restore.direct.json index e94e5bfc71a..6aec05f1661 100644 --- a/acceptance/bundle/resources/postgres_endpoints/update_autoscaling/out.plan.restore.direct.json +++ b/acceptance/bundle/resources/postgres_endpoints/update_autoscaling/out.plan.restore.direct.json @@ -38,7 +38,7 @@ "suspend_timeout_duration": { "action": "skip", "reason": "empty", - "old": "0s", + "old": "300s", "new": "300s" } } diff --git a/acceptance/bundle/resources/postgres_endpoints/update_autoscaling/out.plan.update.direct.json b/acceptance/bundle/resources/postgres_endpoints/update_autoscaling/out.plan.update.direct.json index a635011f732..3d74497e283 100644 --- a/acceptance/bundle/resources/postgres_endpoints/update_autoscaling/out.plan.update.direct.json +++ b/acceptance/bundle/resources/postgres_endpoints/update_autoscaling/out.plan.update.direct.json @@ -38,7 +38,7 @@ "suspend_timeout_duration": { "action": "skip", "reason": "empty", - "old": "0s", + "old": "300s", "new": "300s" } } diff --git a/acceptance/bundle/resources/postgres_projects/update_display_name/out.plan.no_change.direct.json b/acceptance/bundle/resources/postgres_projects/update_display_name/out.plan.no_change.direct.json index cd98841504e..02eada6bb5c 100644 --- a/acceptance/bundle/resources/postgres_projects/update_display_name/out.plan.no_change.direct.json +++ b/acceptance/bundle/resources/postgres_projects/update_display_name/out.plan.no_change.direct.json @@ -30,7 +30,7 @@ "old": { "autoscaling_limit_max_cu": 4, "autoscaling_limit_min_cu": 0.5, - "suspend_timeout_duration": "0s" + "suspend_timeout_duration": "300s" }, "new": { "autoscaling_limit_max_cu": 4, @@ -47,7 +47,7 @@ "history_retention_duration": { "action": "skip", "reason": "empty", - "old": "0s", + "old": "604800s", "new": "604800s" }, "pg_version": { diff --git a/acceptance/bundle/resources/postgres_projects/update_display_name/out.plan.restore.direct.json b/acceptance/bundle/resources/postgres_projects/update_display_name/out.plan.restore.direct.json index 379e1e17cb6..de9277d15f0 100644 --- a/acceptance/bundle/resources/postgres_projects/update_display_name/out.plan.restore.direct.json +++ b/acceptance/bundle/resources/postgres_projects/update_display_name/out.plan.restore.direct.json @@ -43,7 +43,7 @@ "old": { "autoscaling_limit_max_cu": 4, "autoscaling_limit_min_cu": 0.5, - "suspend_timeout_duration": "0s" + "suspend_timeout_duration": "300s" }, "new": { "autoscaling_limit_max_cu": 4, @@ -59,7 +59,7 @@ "history_retention_duration": { "action": "skip", "reason": "empty", - "old": "0s", + "old": "604800s", "new": "604800s" }, "pg_version": { diff --git a/acceptance/bundle/resources/postgres_projects/update_display_name/out.plan.update.direct.json b/acceptance/bundle/resources/postgres_projects/update_display_name/out.plan.update.direct.json index d76ab11bc22..d6307318bc8 100644 --- a/acceptance/bundle/resources/postgres_projects/update_display_name/out.plan.update.direct.json +++ b/acceptance/bundle/resources/postgres_projects/update_display_name/out.plan.update.direct.json @@ -43,7 +43,7 @@ "old": { "autoscaling_limit_max_cu": 4, "autoscaling_limit_min_cu": 0.5, - "suspend_timeout_duration": "0s" + "suspend_timeout_duration": "300s" }, "new": { "autoscaling_limit_max_cu": 4, @@ -59,7 +59,7 @@ "history_retention_duration": { "action": "skip", "reason": "empty", - "old": "0s", + "old": "604800s", "new": "604800s" }, "pg_version": {