Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nextchanges/bundles/postgres-update-mask.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed deploys of Lakebase (`postgres_*`) resources failing with `400 INVALID_PARAMETER_VALUE — Unknown field path in update_mask`. The `update_mask` is now built from a static per-resource list of the field paths the API accepts, instead of from the plan's change list, which could name an individual list element (`spec.custom_tags[0].value`), a field the API only accepts under its oneof group name (`spec.ttl` instead of `spec.expiration`), or an immutable field.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"method": "PATCH",
"path": "/api/2.0/postgres/[DEV_BRANCH_ID]",
"q": {
"update_mask": "spec.is_protected"
"update_mask": "spec.expiration,spec.is_protected"
},
"body": {
"spec": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"method": "PATCH",
"path": "/api/2.0/postgres/[DEV_BRANCH_ID]",
"q": {
"update_mask": "spec.is_protected"
"update_mask": "spec.expiration,spec.is_protected"
},
"body": {
"spec": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"method": "PATCH",
"path": "/api/2.0/postgres/[MY_DATABASE_ID]",
"q": {
"update_mask": "spec.postgres_database"
"update_mask": "spec.postgres_database,spec.role"
},
"body": {
"spec": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"method": "PATCH",
"path": "/api/2.0/postgres/[MY_DATABASE_ID]",
"q": {
"update_mask": "spec.postgres_database"
"update_mask": "spec.postgres_database,spec.role"
},
"body": {
"spec": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"method": "PATCH",
"path": "/api/2.0/postgres/[MY_ENDPOINT_ID]",
"q": {
"update_mask": "spec.autoscaling_limit_max_cu"
"update_mask": "spec.autoscaling_limit_max_cu,spec.autoscaling_limit_min_cu,spec.suspension"
},
"body": {
"spec": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"method": "PATCH",
"path": "/api/2.0/postgres/[MY_ENDPOINT_ID]",
"q": {
"update_mask": "spec.autoscaling_limit_max_cu"
"update_mask": "spec.autoscaling_limit_max_cu,spec.autoscaling_limit_min_cu,spec.suspension"
},
"body": {
"spec": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
# on. Anything the backend returns outside this list is dropped, so a new field
# in the API response does not break the golden.
project_fields() {
jq '{name, project_id, status: (.status | {branch_logical_size_limit_bytes, default_branch, default_endpoint_settings, display_name, enable_pg_native_login, history_retention_duration, owner, pg_version, project_id, synthetic_storage_size_bytes}), uid}'
jq '{name, project_id, status: (.status | {branch_logical_size_limit_bytes, custom_tags, default_branch, default_endpoint_settings, display_name, enable_pg_native_login, history_retention_duration, owner, pg_version, project_id, synthetic_storage_size_bytes} | with_entries(select(.value != null))), uid}'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
bundle:
name: update-postgres-project-tags-$UNIQUE_NAME

sync:
paths: []

resources:
postgres_projects:
my_project:
project_id: test-pg-proj-$UNIQUE_NAME
display_name: "Tagged Project"
pg_version: 16
custom_tags:
- key: release_id
value: "release-1"
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"method": "POST",
"path": "/api/2.0/postgres/projects",
"q": {
"project_id": "test-pg-proj-[UNIQUE_NAME]"
},
"body": {
"spec": {
"custom_tags": [
{
"key": "release_id",
"value": "release-1"
}
],
"display_name": "Tagged Project",
"pg_version": 16
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"method": "GET",
"path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]"
}
{
"method": "PATCH",
"path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]",
"q": {
"update_mask": "spec.custom_tags,spec.display_name"
},
"body": {
"spec": {
"custom_tags": [
{
"key": "release_id",
"value": "release-2"
}
],
"display_name": "Tagged Project",
"pg_version": 16
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@

=== Initial deployment
>>> [CLI] bundle deploy
Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-project-tags-[UNIQUE_NAME]/default/files...
Created postgres_projects.my_project
Files: 0 uploaded, 0 deleted
Resources: 1 created, 0 changed, 0 deleted, 0 unchanged

>>> print_requests.py --del-body project_id,branch_id,endpoint_id,database_id,role_id,catalog_id,synced_table_id --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/

>>> [CLI] postgres get-project projects/test-pg-proj-[UNIQUE_NAME]
{
"name": "projects/test-pg-proj-[UNIQUE_NAME]",
"project_id": "test-pg-proj-[UNIQUE_NAME]",
"status": {
"branch_logical_size_limit_bytes": [NUMID],
"custom_tags": [
{
"key": "release_id",
"value": "release-1"
}
],
"default_branch": "projects/test-pg-proj-[UNIQUE_NAME]/branches/production",
"default_endpoint_settings": {
"autoscaling_limit_max_cu": 1,
"autoscaling_limit_min_cu": 1,
"suspend_timeout_duration": "86400s"
},
"display_name": "Tagged Project",
"enable_pg_native_login": false,
"history_retention_duration": "604800s",
"owner": "[USERNAME]",
"pg_version": 16,
"project_id": "test-pg-proj-[UNIQUE_NAME]",
"synthetic_storage_size_bytes": 0
},
"uid": "[UUID]"
}

=== Change the value of an existing custom tag and re-deploy
>>> update_file.py databricks.yml release-1 release-2

>>> [CLI] bundle plan
update postgres_projects.my_project

Plan: 0 to add, 1 to change, 0 to delete, 0 unchanged

>>> [CLI] bundle deploy
Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/update-postgres-project-tags-[UNIQUE_NAME]/default/files...
Updated postgres_projects.my_project
Files: 0 uploaded, 0 deleted
Resources: 0 created, 1 changed, 0 deleted, 0 unchanged

>>> print_requests.py --del-body project_id,branch_id,endpoint_id,database_id,role_id,catalog_id,synced_table_id --keep --get //postgres ^//workspace-files/ ^//workspace/ ^//telemetry-ext ^//operations/

>>> [CLI] postgres get-project projects/test-pg-proj-[UNIQUE_NAME]
{
"name": "projects/test-pg-proj-[UNIQUE_NAME]",
"project_id": "test-pg-proj-[UNIQUE_NAME]",
"status": {
"branch_logical_size_limit_bytes": [NUMID],
"custom_tags": [
{
"key": "release_id",
"value": "release-2"
}
],
"default_branch": "projects/test-pg-proj-[UNIQUE_NAME]/branches/production",
"default_endpoint_settings": {
"autoscaling_limit_max_cu": 1,
"autoscaling_limit_min_cu": 1,
"suspend_timeout_duration": "86400s"
},
"display_name": "Tagged Project",
"enable_pg_native_login": false,
"history_retention_duration": "604800s",
"owner": "[USERNAME]",
"pg_version": 16,
"project_id": "test-pg-proj-[UNIQUE_NAME]",
"synthetic_storage_size_bytes": 0
},
"uid": "[UUID]"
}

>>> [CLI] bundle destroy --auto-approve
The following resources will be deleted:
delete resources.postgres_projects.my_project

This action will result in the deletion of the following Lakebase projects along with
all their branches, databases, and endpoints. All data stored in them will be permanently lost:
delete resources.postgres_projects.my_project

All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/update-postgres-project-tags-[UNIQUE_NAME]/default

Destroy: 1 deleted
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
envsubst < databricks.yml.tmpl > databricks.yml

cleanup() {
trace $CLI bundle destroy --auto-approve
rm -f out.requests.txt
}
trap cleanup EXIT

print_requests() {
local name=$1
trace print_requests.py --del-body project_id,branch_id,endpoint_id,database_id,role_id,catalog_id,synced_table_id --keep --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.${name}.$DATABRICKS_BUNDLE_ENGINE.json
rm -f out.requests.txt
}

title "Initial deployment"
rm -f out.requests.txt
trace $CLI bundle deploy
print_requests create

project_name="projects/test-pg-proj-${UNIQUE_NAME}"
trace $CLI postgres get-project "${project_name}" | project_fields

title "Change the value of an existing custom tag and re-deploy"
trace update_file.py databricks.yml "release-1" "release-2"

trace $CLI bundle plan
rm -f out.requests.txt
trace $CLI bundle deploy
print_requests update

trace $CLI postgres get-project "${project_name}" | project_fields
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# The direct engine is the one that builds an update_mask from the plan's change
# list; terraform sends its own mask.
EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"]
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"method": "PATCH",
"path": "/api/2.0/postgres/[MY_PROJECT_ID]",
"q": {
"update_mask": "spec.display_name"
"update_mask": "spec.default_endpoint_settings.autoscaling_limit_max_cu,spec.default_endpoint_settings.autoscaling_limit_min_cu,spec.default_endpoint_settings.suspension,spec.display_name,spec.history_retention_duration"
},
"body": {
"spec": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"method": "PATCH",
"path": "/api/2.0/postgres/[MY_PROJECT_ID]",
"q": {
"update_mask": "spec.display_name"
"update_mask": "spec.default_endpoint_settings.autoscaling_limit_max_cu,spec.default_endpoint_settings.autoscaling_limit_min_cu,spec.default_endpoint_settings.suspension,spec.display_name,spec.history_retention_duration"
},
"body": {
"spec": {
Expand Down
2 changes: 2 additions & 0 deletions bundle/direct/dresources/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ 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.

A static list is not always the same shape as the API's own field names. The Postgres endpoints (see `specUpdateMaskPaths` and the `*SpecUpdateMask` tables) need a static map rather than a static list, because the API rejects `*`: it expands the mask to leaves and requires every leaf to be populated in the request body, which a bundle that omits optional fields never is. The same tables record the two other ways an API field name and its mask path differ — members of a oneof are masked under the group name (`spec.ttl` must be sent as `spec.expiration`), and immutable fields have no mask path at all.

## Async APIs: WaitAfterCreate / WaitAfterUpdate

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.
Expand Down
46 changes: 29 additions & 17 deletions bundle/direct/dresources/postgres_branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package dresources

import (
"context"
"slices"

"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/databricks-sdk-go"
Expand Down Expand Up @@ -140,26 +139,39 @@ func (r *ResourcePostgresBranch) DoCreate(ctx context.Context, config *PostgresB
return remote.Name, remote, nil
}

// branchSpecUpdateMask maps every postgres.BranchSpec field to the update_mask
// path UpdateBranch accepts for it. Verified against the API on 2026-08-24;
// TestPostgresSpecUpdateMasks keeps it in step with the SDK type.
var branchSpecUpdateMask = specUpdateMask{
"is_protected": "is_protected",

// expire_time, no_expiry and ttl are three sides of one oneof; the API masks
// them together under "expiration" and rejects each field name on its own.
"expire_time": "expiration",
"no_expiry": "expiration",
"ttl": "expiration",

// The source_* fields describe where the branch was forked from and have no
// update_mask path. A change to them recreates the branch
// (recreate_on_changes in resources.generated.yml).
"source_branch": "",
"source_branch_lsn": "",
"source_branch_time": "",
}

func (r *ResourcePostgresBranch) DoUpdate(ctx context.Context, 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 "*"
// expands to nested attributes the body would have to set too — so we
// can't use a static all-fields mask. The change list naturally tracks
// what the user actually set, so the body and mask stay consistent.
fieldPaths := collectUpdatePathsWithPrefix(entry.Changes, "spec.")

// purge_on_delete is an input-only flag consulted at delete time; it is
// not a spec field. Strip it from the mask so toggling it between deploys
// becomes a state-only refresh (the framework saves newState when this
// returns nil error).
fieldPaths = slices.DeleteFunc(fieldPaths, func(p string) bool {
return p == "spec.purge_on_delete"
})
if len(fieldPaths) == 0 {
// purge_on_delete is an input-only flag consulted at delete time; it is not a
// spec field, so toggling it between deploys is a state-only refresh (the
// framework saves newState when this returns a nil error).
if !hasSpecChanges(entry.Changes, "purge_on_delete") {
return nil, nil
}

fieldPaths, err := specUpdateMaskPaths(&config.BranchSpec, branchSpecUpdateMask)
if err != nil {
return nil, err
}

waiter, err := r.client.Postgres.UpdateBranch(ctx, postgres.UpdateBranchRequest{
Branch: postgres.Branch{
Spec: &config.BranchSpec,
Expand Down
17 changes: 12 additions & 5 deletions bundle/direct/dresources/postgres_database.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,19 @@ func (r *ResourcePostgresDatabase) DoCreate(ctx context.Context, config *Postgre
return remote.Name, remote, nil
}

// databaseSpecUpdateMask maps every postgres.DatabaseDatabaseSpec field to the
// update_mask path UpdateDatabase accepts for it. Verified against the API on
// 2026-08-24; TestPostgresSpecUpdateMasks keeps it in step with the SDK type.
var databaseSpecUpdateMask = specUpdateMask{
"postgres_database": "postgres_database",
"role": "role",
}

func (r *ResourcePostgresDatabase) DoUpdate(ctx context.Context, 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,
// not relative to our flattened state type.
fieldPaths := collectLeafUpdatePathsWithPrefix(entry.Changes, "spec.")
fieldPaths, err := specUpdateMaskPaths(&config.DatabaseDatabaseSpec, databaseSpecUpdateMask)
if err != nil {
return nil, err
}

waiter, err := r.client.Postgres.UpdateDatabase(ctx, postgres.UpdateDatabaseRequest{
Database: postgres.Database{
Expand Down
Loading
Loading