diff --git a/.nextchanges/bundles/postgres-snapshot-schedules.md b/.nextchanges/bundles/postgres-snapshot-schedules.md new file mode 100644 index 00000000000..b54efd7611f --- /dev/null +++ b/.nextchanges/bundles/postgres-snapshot-schedules.md @@ -0,0 +1 @@ +Add the `postgres_snapshot_schedules` bundle resource for managing a Lakebase Postgres branch's automatic-snapshot schedule (direct deployment engine only). diff --git a/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/databricks.yml b/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/databricks.yml new file mode 100644 index 00000000000..d680805158d --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/databricks.yml @@ -0,0 +1,11 @@ +bundle: + name: test-bundle + +resources: + postgres_snapshot_schedules: + schedule1: + branch: projects/test-project/branches/main + schedule: + - daily_schedule: + hour: 3 + retention: "604800s" diff --git a/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/out.test.toml b/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/out.test.toml new file mode 100644 index 00000000000..0938e678987 --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/out.test.toml @@ -0,0 +1,2 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/output.txt b/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/output.txt new file mode 100644 index 00000000000..71102ecfd2c --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/output.txt @@ -0,0 +1,30 @@ + +>>> [CLI] bundle deployment bind schedule1 projects/test-project/branches/main/snapshot-schedule --auto-approve +Successfully bound postgres_snapshot_schedule with an id 'projects/test-project/branches/main/snapshot-schedule' +Run 'bundle deploy' to deploy changes to your workspace + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + Postgres snapshot schedules: + schedule1: + Name: + URL: (not deployed) + +>>> [CLI] bundle deployment unbind schedule1 + +>>> [CLI] bundle summary +Name: test-bundle +Target: default +Workspace: + User: [USERNAME] + Path: /Workspace/Users/[USERNAME]/.bundle/test-bundle/default +Resources: + Postgres snapshot schedules: + schedule1: + Name: + URL: (not deployed) diff --git a/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/script b/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/script new file mode 100644 index 00000000000..ee70c6a35bd --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/script @@ -0,0 +1,6 @@ +SCHEDULE_NAME="projects/test-project/branches/main/snapshot-schedule" +trace $CLI bundle deployment bind schedule1 "${SCHEDULE_NAME}" --auto-approve +trace $CLI bundle summary + +trace $CLI bundle deployment unbind schedule1 +trace $CLI bundle summary diff --git a/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/test.toml b/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/test.toml new file mode 100644 index 00000000000..dc7816a8cd1 --- /dev/null +++ b/acceptance/bundle/deployment/bind/postgres_snapshot_schedule/test.toml @@ -0,0 +1,26 @@ +Cloud = false + +# The snapshot schedule was added to the Postgres API in databricks-sdk-go +# v0.177.0, which the pinned Terraform provider does not yet include, so there is +# no databricks_..._snapshot_schedule resource. Run the direct engine only. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +Ignore = [ + ".databricks" +] + +[[Server]] +Pattern = "GET /api/2.0/postgres/projects/test-project/branches/main/snapshot-schedule" +Response.Body = ''' +{ + "name": "projects/test-project/branches/main/snapshot-schedule", + "schedule": [ + { + "daily_schedule": { + "hour": 3 + }, + "retention": "604800s" + } + ] +} +''' diff --git a/acceptance/bundle/invariant/configs/postgres_snapshot_schedule.yml.tmpl b/acceptance/bundle/invariant/configs/postgres_snapshot_schedule.yml.tmpl new file mode 100644 index 00000000000..5e36e89886c --- /dev/null +++ b/acceptance/bundle/invariant/configs/postgres_snapshot_schedule.yml.tmpl @@ -0,0 +1,22 @@ +bundle: + name: test-bundle-$UNIQUE_NAME + +resources: + postgres_projects: + project: + project_id: test-pg-project-$UNIQUE_NAME + display_name: Test Postgres Project + + postgres_branches: + foo: + parent: ${resources.postgres_projects.project.name} + branch_id: test-branch-$UNIQUE_NAME + no_expiry: true + + postgres_snapshot_schedules: + foo: + branch: ${resources.postgres_branches.foo.name} + schedule: + - daily_schedule: + hour: 3 + retention: "604800s" diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index 748939d3c56..b231604a2ba 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -3556,6 +3556,24 @@ resources.postgres_roles.*.status.postgres_role string REMOTE resources.postgres_roles.*.status.role_id string REMOTE resources.postgres_roles.*.update_time *time.Time REMOTE resources.postgres_roles.*.url string INPUT +resources.postgres_snapshot_schedules.*.branch string ALL +resources.postgres_snapshot_schedules.*.id string INPUT +resources.postgres_snapshot_schedules.*.lifecycle resources.Lifecycle INPUT +resources.postgres_snapshot_schedules.*.lifecycle.prevent_destroy bool INPUT +resources.postgres_snapshot_schedules.*.modified_status string INPUT +resources.postgres_snapshot_schedules.*.name string REMOTE +resources.postgres_snapshot_schedules.*.schedule []postgres.ScheduleCadence ALL +resources.postgres_snapshot_schedules.*.schedule[*] postgres.ScheduleCadence ALL +resources.postgres_snapshot_schedules.*.schedule[*].daily_schedule *postgres.DailySchedule ALL +resources.postgres_snapshot_schedules.*.schedule[*].daily_schedule.hour int ALL +resources.postgres_snapshot_schedules.*.schedule[*].monthly_schedule *postgres.MonthlySchedule ALL +resources.postgres_snapshot_schedules.*.schedule[*].monthly_schedule.day int ALL +resources.postgres_snapshot_schedules.*.schedule[*].monthly_schedule.hour int ALL +resources.postgres_snapshot_schedules.*.schedule[*].retention duration.Duration ALL +resources.postgres_snapshot_schedules.*.schedule[*].weekly_schedule *postgres.WeeklySchedule ALL +resources.postgres_snapshot_schedules.*.schedule[*].weekly_schedule.day_of_week postgres.DayOfWeek ALL +resources.postgres_snapshot_schedules.*.schedule[*].weekly_schedule.hour int ALL +resources.postgres_snapshot_schedules.*.url string INPUT resources.postgres_synced_tables.*.accelerated_sync bool ALL resources.postgres_synced_tables.*.branch string ALL resources.postgres_synced_tables.*.create_database_objects_if_missing bool ALL diff --git a/acceptance/bundle/resources/postgres_snapshot_schedules/basic/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_snapshot_schedules/basic/databricks.yml.tmpl new file mode 100644 index 00000000000..a951ec2a7cd --- /dev/null +++ b/acceptance/bundle/resources/postgres_snapshot_schedules/basic/databricks.yml.tmpl @@ -0,0 +1,26 @@ +bundle: + name: deploy-pg-ss-basic-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Snapshot Schedules" + pg_version: 16 + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_snapshot_schedules: + main_schedule: + branch: ${resources.postgres_branches.main.id} + schedule: + - daily_schedule: + hour: 3 + retention: "604800s" diff --git a/acceptance/bundle/resources/postgres_snapshot_schedules/basic/out.requests.create.txt b/acceptance/bundle/resources/postgres_snapshot_schedules/basic/out.requests.create.txt new file mode 100644 index 00000000000..28332f384de --- /dev/null +++ b/acceptance/bundle/resources/postgres_snapshot_schedules/basic/out.requests.create.txt @@ -0,0 +1,46 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule", + "q": { + "update_mask": "schedule" + }, + "body": { + "schedule": [ + { + "daily_schedule": { + "hour": 3 + }, + "retention": "604800s" + } + ] + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects", + "q": { + "project_id": "test-pg-proj-[UNIQUE_NAME]" + }, + "body": { + "spec": { + "display_name": "Test Project for Snapshot Schedules", + "pg_version": 16 + } + } +} +{ + "method": "POST", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches", + "q": { + "branch_id": "main" + }, + "body": { + "spec": { + "no_expiry": true + } + } +} diff --git a/acceptance/bundle/resources/postgres_snapshot_schedules/basic/out.requests.remove.txt b/acceptance/bundle/resources/postgres_snapshot_schedules/basic/out.requests.remove.txt new file mode 100644 index 00000000000..9b0ebd46820 --- /dev/null +++ b/acceptance/bundle/resources/postgres_snapshot_schedules/basic/out.requests.remove.txt @@ -0,0 +1,28 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule", + "q": { + "update_mask": "schedule" + }, + "body": {} +} diff --git a/acceptance/bundle/resources/postgres_snapshot_schedules/basic/out.requests.update.txt b/acceptance/bundle/resources/postgres_snapshot_schedules/basic/out.requests.update.txt new file mode 100644 index 00000000000..dcaaf4977e1 --- /dev/null +++ b/acceptance/bundle/resources/postgres_snapshot_schedules/basic/out.requests.update.txt @@ -0,0 +1,41 @@ +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule" +} +{ + "method": "GET", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule" +} +{ + "method": "PATCH", + "path": "/api/2.0/postgres/projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule", + "q": { + "update_mask": "schedule" + }, + "body": { + "schedule": [ + { + "daily_schedule": { + "hour": 5 + }, + "retention": "604800s" + } + ] + } +} diff --git a/acceptance/bundle/resources/postgres_snapshot_schedules/basic/out.test.toml b/acceptance/bundle/resources/postgres_snapshot_schedules/basic/out.test.toml new file mode 100644 index 00000000000..1314e661c24 --- /dev/null +++ b/acceptance/bundle/resources/postgres_snapshot_schedules/basic/out.test.toml @@ -0,0 +1,4 @@ +Cloud = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/postgres_snapshot_schedules/basic/output.txt b/acceptance/bundle/resources/postgres_snapshot_schedules/basic/output.txt new file mode 100644 index 00000000000..c7eff1ef484 --- /dev/null +++ b/acceptance/bundle/resources/postgres_snapshot_schedules/basic/output.txt @@ -0,0 +1,97 @@ + +=== Create with a snapshot schedule +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-pg-ss-basic-[UNIQUE_NAME]/default/files... +Created postgres_branches.main +Created postgres_projects.my_project +Created postgres_snapshot_schedules.main_schedule +Files: 0 uploaded, 0 deleted +Resources: 3 created, 0 changed, 0 deleted, 0 unchanged + +>>> [CLI] postgres get-snapshot-schedule projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule", + "schedule": [ + { + "daily_schedule": { + "hour": 3 + }, + "retention": "604800s" + } + ] +} + +=== Update the schedule cadence +>>> [CLI] bundle deploy --auto-approve +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-pg-ss-basic-[UNIQUE_NAME]/default/files... +Updated postgres_snapshot_schedules.main_schedule +Files: 0 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 2 unchanged + +>>> [CLI] postgres get-snapshot-schedule projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule", + "schedule": [ + { + "daily_schedule": { + "hour": 5 + }, + "retention": "604800s" + } + ] +} + +=== Remove the snapshot schedule resource (disables automatic snapshots) +>>> cat databricks.yml +bundle: + name: deploy-pg-ss-basic-[UNIQUE_NAME] + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-[UNIQUE_NAME] + display_name: "Test Project for Snapshot Schedules" + pg_version: 16 + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + +>>> [CLI] bundle deploy --auto-approve +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-pg-ss-basic-[UNIQUE_NAME]/default/files... +Deleted postgres_snapshot_schedules.main_schedule +Files: 0 uploaded, 0 deleted +Resources: 0 created, 0 changed, 1 deleted, 2 unchanged + +>>> [CLI] postgres get-snapshot-schedule projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule", + "schedule": null +} + +=== Plan again to confirm no drift after removal +>>> [CLI] bundle plan +Plan: 0 to add, 0 to change, 0 to delete, 2 unchanged + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + 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 + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-pg-ss-basic-[UNIQUE_NAME]/default + +Destroy: 2 deleted diff --git a/acceptance/bundle/resources/postgres_snapshot_schedules/basic/script b/acceptance/bundle/resources/postgres_snapshot_schedules/basic/script new file mode 100644 index 00000000000..77c51554a9d --- /dev/null +++ b/acceptance/bundle/resources/postgres_snapshot_schedules/basic/script @@ -0,0 +1,30 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +schedule_name="projects/test-pg-proj-${UNIQUE_NAME}/branches/main/snapshot-schedule" + +title "Create with a snapshot schedule" +envsubst < databricks.yml.tmpl > databricks.yml +rm -f out.requests.txt +trace $CLI bundle deploy +trace $CLI postgres get-snapshot-schedule "${schedule_name}" | snapshot_schedule_fields +print_requests.py --del-body parent,project_id,branch_id --sort --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.create.txt + +title "Update the schedule cadence" +sed "s/hour: 3/hour: 5/" databricks.yml > databricks.yml.new && mv databricks.yml.new databricks.yml +trace $CLI bundle deploy --auto-approve +trace $CLI postgres get-snapshot-schedule "${schedule_name}" | snapshot_schedule_fields +print_requests.py --del-body parent,project_id,branch_id --sort --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.update.txt + +title "Remove the snapshot schedule resource (disables automatic snapshots)" +sed '/postgres_snapshot_schedules:/,$d' databricks.yml > databricks.yml.new && mv databricks.yml.new databricks.yml +trace cat databricks.yml +trace $CLI bundle deploy --auto-approve +trace $CLI postgres get-snapshot-schedule "${schedule_name}" | snapshot_schedule_fields +print_requests.py --del-body parent,project_id,branch_id --sort --get '//postgres' '^//workspace-files/' '^//workspace/' '^//telemetry-ext' '^//operations/' > out.requests.remove.txt + +title "Plan again to confirm no drift after removal" +trace $CLI bundle plan diff --git a/acceptance/bundle/resources/postgres_snapshot_schedules/out-of-band/databricks.yml.tmpl b/acceptance/bundle/resources/postgres_snapshot_schedules/out-of-band/databricks.yml.tmpl new file mode 100644 index 00000000000..e24897277b9 --- /dev/null +++ b/acceptance/bundle/resources/postgres_snapshot_schedules/out-of-band/databricks.yml.tmpl @@ -0,0 +1,26 @@ +bundle: + name: deploy-pg-ss-oob-$UNIQUE_NAME + +sync: + paths: [] + +resources: + postgres_projects: + my_project: + project_id: test-pg-proj-$UNIQUE_NAME + display_name: "Test Project for Snapshot Schedules" + pg_version: 16 + + postgres_branches: + main: + parent: ${resources.postgres_projects.my_project.id} + branch_id: main + no_expiry: true + + postgres_snapshot_schedules: + main_schedule: + branch: ${resources.postgres_branches.main.id} + schedule: + - daily_schedule: + hour: 3 + retention: "604800s" diff --git a/acceptance/bundle/resources/postgres_snapshot_schedules/out-of-band/out.test.toml b/acceptance/bundle/resources/postgres_snapshot_schedules/out-of-band/out.test.toml new file mode 100644 index 00000000000..1314e661c24 --- /dev/null +++ b/acceptance/bundle/resources/postgres_snapshot_schedules/out-of-band/out.test.toml @@ -0,0 +1,4 @@ +Cloud = true +CloudEnvs.azure = false +CloudEnvs.gcp = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/resources/postgres_snapshot_schedules/out-of-band/output.txt b/acceptance/bundle/resources/postgres_snapshot_schedules/out-of-band/output.txt new file mode 100644 index 00000000000..41786d6076c --- /dev/null +++ b/acceptance/bundle/resources/postgres_snapshot_schedules/out-of-band/output.txt @@ -0,0 +1,80 @@ + +=== Deploy with a snapshot schedule +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-pg-ss-oob-[UNIQUE_NAME]/default/files... +Created postgres_branches.main +Created postgres_projects.my_project +Created postgres_snapshot_schedules.main_schedule +Files: 0 uploaded, 0 deleted +Resources: 3 created, 0 changed, 0 deleted, 0 unchanged + +>>> [CLI] postgres get-snapshot-schedule projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule", + "schedule": [ + { + "daily_schedule": { + "hour": 3 + }, + "retention": "604800s" + } + ] +} + +=== Change the schedule out of band via a direct API call +>>> [CLI] postgres update-snapshot-schedule projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule schedule --json {"schedule":[{"daily_schedule":{"hour":9},"retention":"604800s"}]} +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule", + "schedule": [ + { + "daily_schedule": { + "hour": 9 + }, + "retention": "604800s" + } + ] +} + +=== Plan detects the drift +>>> [CLI] bundle plan +update postgres_snapshot_schedules.main_schedule + +Plan: 0 to add, 1 to change, 0 to delete, 2 unchanged + +=== Deploy reconciles the schedule back to the configured cadence +>>> [CLI] bundle deploy --auto-approve +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/deploy-pg-ss-oob-[UNIQUE_NAME]/default/files... +Updated postgres_snapshot_schedules.main_schedule +Files: 0 uploaded, 0 deleted +Resources: 0 created, 1 changed, 0 deleted, 2 unchanged + +>>> [CLI] postgres get-snapshot-schedule projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule +{ + "name": "projects/test-pg-proj-[UNIQUE_NAME]/branches/main/snapshot-schedule", + "schedule": [ + { + "daily_schedule": { + "hour": 3 + }, + "retention": "604800s" + } + ] +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.postgres_branches.main + delete resources.postgres_projects.my_project + delete resources.postgres_snapshot_schedules.main_schedule + +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 + +This action will result in the deletion of the following Lakebase branches. +All data stored in them will be permanently lost: + delete resources.postgres_branches.main + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/deploy-pg-ss-oob-[UNIQUE_NAME]/default + +Destroy: 3 deleted diff --git a/acceptance/bundle/resources/postgres_snapshot_schedules/out-of-band/script b/acceptance/bundle/resources/postgres_snapshot_schedules/out-of-band/script new file mode 100644 index 00000000000..59d2cf6254e --- /dev/null +++ b/acceptance/bundle/resources/postgres_snapshot_schedules/out-of-band/script @@ -0,0 +1,22 @@ +cleanup() { + trace $CLI bundle destroy --auto-approve + rm -f out.requests.txt +} +trap cleanup EXIT + +schedule_name="projects/test-pg-proj-${UNIQUE_NAME}/branches/main/snapshot-schedule" + +title "Deploy with a snapshot schedule" +envsubst < databricks.yml.tmpl > databricks.yml +trace $CLI bundle deploy +trace $CLI postgres get-snapshot-schedule "${schedule_name}" | snapshot_schedule_fields + +title "Change the schedule out of band via a direct API call" +trace $CLI postgres update-snapshot-schedule "${schedule_name}" schedule --json '{"schedule":[{"daily_schedule":{"hour":9},"retention":"604800s"}]}' | snapshot_schedule_fields + +title "Plan detects the drift" +trace $CLI bundle plan + +title "Deploy reconciles the schedule back to the configured cadence" +trace $CLI bundle deploy --auto-approve +trace $CLI postgres get-snapshot-schedule "${schedule_name}" | snapshot_schedule_fields diff --git a/acceptance/bundle/resources/postgres_snapshot_schedules/script.prepare b/acceptance/bundle/resources/postgres_snapshot_schedules/script.prepare new file mode 100644 index 00000000000..ce34a2474d5 --- /dev/null +++ b/acceptance/bundle/resources/postgres_snapshot_schedules/script.prepare @@ -0,0 +1,6 @@ +# snapshot_schedule_fields is the allow-list of snapshot-schedule fields the +# acceptance tests assert on. Anything the backend returns outside this list is +# dropped, so a new field in the API response does not break the golden. +snapshot_schedule_fields() { + jq '{name, schedule}' +} diff --git a/acceptance/bundle/resources/postgres_snapshot_schedules/test.toml b/acceptance/bundle/resources/postgres_snapshot_schedules/test.toml new file mode 100644 index 00000000000..888670d085c --- /dev/null +++ b/acceptance/bundle/resources/postgres_snapshot_schedules/test.toml @@ -0,0 +1,16 @@ +Cloud = true + +# Lakebase v2 (postgres) is only available in AWS as of January 2026 +CloudEnvs.gcp = false +CloudEnvs.azure = false + +# The snapshot schedule was added to the Postgres API in databricks-sdk-go +# v0.177.0, which the pinned Terraform provider does not yet include, so there is +# no databricks_..._snapshot_schedule resource to deploy through. Run the direct +# engine only until the provider catches up. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +Ignore = [ + "databricks.yml", + ".databricks", +] diff --git a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go index 99ac6759ee8..27202a08dad 100644 --- a/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go +++ b/bundle/config/mutator/resourcemutator/apply_bundle_permissions_test.go @@ -32,6 +32,7 @@ var unsupportedResources = []string{ "postgres_endpoints", "postgres_catalogs", "postgres_roles", + "postgres_snapshot_schedules", "postgres_synced_tables", "vector_search_indexes", "job_runs", diff --git a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go index 08c76f8433d..eaf33516763 100644 --- a/bundle/config/mutator/resourcemutator/apply_target_mode_test.go +++ b/bundle/config/mutator/resourcemutator/apply_target_mode_test.go @@ -305,6 +305,13 @@ func mockBundle(mode config.Mode) *bundle.Bundle { }, }, }, + PostgresSnapshotSchedules: map[string]*resources.PostgresSnapshotSchedule{ + "postgres_snapshot_schedule1": { + PostgresSnapshotScheduleConfig: resources.PostgresSnapshotScheduleConfig{ + Branch: "projects/project1/branches/branch1", + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "vs_endpoint1": { CreateEndpoint: vectorsearch.CreateEndpoint{ diff --git a/bundle/config/mutator/resourcemutator/run_as_test.go b/bundle/config/mutator/resourcemutator/run_as_test.go index be9abfe6836..1df273c44b2 100644 --- a/bundle/config/mutator/resourcemutator/run_as_test.go +++ b/bundle/config/mutator/resourcemutator/run_as_test.go @@ -56,6 +56,7 @@ func allResourceTypes(t *testing.T) []string { "postgres_endpoints", "postgres_projects", "postgres_roles", + "postgres_snapshot_schedules", "postgres_synced_tables", "quality_monitors", "registered_models", @@ -191,6 +192,7 @@ var allowList = []string{ "postgres_endpoints", "postgres_projects", "postgres_roles", + "postgres_snapshot_schedules", "postgres_synced_tables", "registered_models", "experiments", diff --git a/bundle/config/resources.go b/bundle/config/resources.go index 633332a78c0..b5e68d992e8 100644 --- a/bundle/config/resources.go +++ b/bundle/config/resources.go @@ -15,37 +15,38 @@ type Resources struct { JobRuns map[string]*resources.JobRun `json:"job_runs,omitempty"` Pipelines map[string]*resources.Pipeline `json:"pipelines,omitempty"` - Models map[string]*resources.MlflowModel `json:"models,omitempty"` - Experiments map[string]*resources.MlflowExperiment `json:"experiments,omitempty"` - ModelServingEndpoints map[string]*resources.ModelServingEndpoint `json:"model_serving_endpoints,omitempty"` - RegisteredModels map[string]*resources.RegisteredModel `json:"registered_models,omitempty"` - QualityMonitors map[string]*resources.QualityMonitor `json:"quality_monitors,omitempty"` - Catalogs map[string]*resources.Catalog `json:"catalogs,omitempty"` - Schemas map[string]*resources.Schema `json:"schemas,omitempty"` - Volumes map[string]*resources.Volume `json:"volumes,omitempty"` - ExternalLocations map[string]*resources.ExternalLocation `json:"external_locations,omitempty"` - Clusters map[string]*resources.Cluster `json:"clusters,omitempty"` - Dashboards map[string]*resources.Dashboard `json:"dashboards,omitempty"` - GenieSpaces map[string]*resources.GenieSpace `json:"genie_spaces,omitempty"` - Apps map[string]*resources.App `json:"apps,omitempty"` - SecretScopes map[string]*resources.SecretScope `json:"secret_scopes,omitempty"` - Alerts map[string]*resources.Alert `json:"alerts,omitempty"` - SqlWarehouses map[string]*resources.SqlWarehouse `json:"sql_warehouses,omitempty"` - DatabaseInstances map[string]*resources.DatabaseInstance `json:"database_instances,omitempty"` - DatabaseCatalogs map[string]*resources.DatabaseCatalog `json:"database_catalogs,omitempty"` - SyncedDatabaseTables map[string]*resources.SyncedDatabaseTable `json:"synced_database_tables,omitempty"` - PostgresProjects map[string]*resources.PostgresProject `json:"postgres_projects,omitempty"` - PostgresBranches map[string]*resources.PostgresBranch `json:"postgres_branches,omitempty"` - PostgresEndpoints map[string]*resources.PostgresEndpoint `json:"postgres_endpoints,omitempty"` - PostgresCatalogs map[string]*resources.PostgresCatalog `json:"postgres_catalogs,omitempty"` - PostgresDatabases map[string]*resources.PostgresDatabase `json:"postgres_databases,omitempty"` - PostgresRoles map[string]*resources.PostgresRole `json:"postgres_roles,omitempty"` - PostgresSyncedTables map[string]*resources.PostgresSyncedTable `json:"postgres_synced_tables,omitempty"` - VectorSearchEndpoints map[string]*resources.VectorSearchEndpoint `json:"vector_search_endpoints,omitempty"` - VectorSearchIndexes map[string]*resources.VectorSearchIndex `json:"vector_search_indexes,omitempty"` - InstancePools map[string]*resources.InstancePool `json:"instance_pools,omitempty"` - Secrets map[string]*resources.Secret `json:"secrets,omitempty"` - ClusterPolicies map[string]*resources.ClusterPolicy `json:"cluster_policies,omitempty"` + Models map[string]*resources.MlflowModel `json:"models,omitempty"` + Experiments map[string]*resources.MlflowExperiment `json:"experiments,omitempty"` + ModelServingEndpoints map[string]*resources.ModelServingEndpoint `json:"model_serving_endpoints,omitempty"` + RegisteredModels map[string]*resources.RegisteredModel `json:"registered_models,omitempty"` + QualityMonitors map[string]*resources.QualityMonitor `json:"quality_monitors,omitempty"` + Catalogs map[string]*resources.Catalog `json:"catalogs,omitempty"` + Schemas map[string]*resources.Schema `json:"schemas,omitempty"` + Volumes map[string]*resources.Volume `json:"volumes,omitempty"` + ExternalLocations map[string]*resources.ExternalLocation `json:"external_locations,omitempty"` + Clusters map[string]*resources.Cluster `json:"clusters,omitempty"` + Dashboards map[string]*resources.Dashboard `json:"dashboards,omitempty"` + GenieSpaces map[string]*resources.GenieSpace `json:"genie_spaces,omitempty"` + Apps map[string]*resources.App `json:"apps,omitempty"` + SecretScopes map[string]*resources.SecretScope `json:"secret_scopes,omitempty"` + Alerts map[string]*resources.Alert `json:"alerts,omitempty"` + SqlWarehouses map[string]*resources.SqlWarehouse `json:"sql_warehouses,omitempty"` + DatabaseInstances map[string]*resources.DatabaseInstance `json:"database_instances,omitempty"` + DatabaseCatalogs map[string]*resources.DatabaseCatalog `json:"database_catalogs,omitempty"` + SyncedDatabaseTables map[string]*resources.SyncedDatabaseTable `json:"synced_database_tables,omitempty"` + PostgresProjects map[string]*resources.PostgresProject `json:"postgres_projects,omitempty"` + PostgresBranches map[string]*resources.PostgresBranch `json:"postgres_branches,omitempty"` + PostgresEndpoints map[string]*resources.PostgresEndpoint `json:"postgres_endpoints,omitempty"` + PostgresCatalogs map[string]*resources.PostgresCatalog `json:"postgres_catalogs,omitempty"` + PostgresDatabases map[string]*resources.PostgresDatabase `json:"postgres_databases,omitempty"` + PostgresRoles map[string]*resources.PostgresRole `json:"postgres_roles,omitempty"` + PostgresSyncedTables map[string]*resources.PostgresSyncedTable `json:"postgres_synced_tables,omitempty"` + PostgresSnapshotSchedules map[string]*resources.PostgresSnapshotSchedule `json:"postgres_snapshot_schedules,omitempty"` + VectorSearchEndpoints map[string]*resources.VectorSearchEndpoint `json:"vector_search_endpoints,omitempty"` + VectorSearchIndexes map[string]*resources.VectorSearchIndex `json:"vector_search_indexes,omitempty"` + InstancePools map[string]*resources.InstancePool `json:"instance_pools,omitempty"` + Secrets map[string]*resources.Secret `json:"secrets,omitempty"` + ClusterPolicies map[string]*resources.ClusterPolicy `json:"cluster_policies,omitempty"` } type ConfigResource interface { @@ -128,6 +129,7 @@ func (r *Resources) AllResources() []ResourceGroup { collectResourceMap(descriptions["postgres_databases"], r.PostgresDatabases), collectResourceMap(descriptions["postgres_roles"], r.PostgresRoles), collectResourceMap(descriptions["postgres_synced_tables"], r.PostgresSyncedTables), + collectResourceMap(descriptions["postgres_snapshot_schedules"], r.PostgresSnapshotSchedules), collectResourceMap(descriptions["vector_search_endpoints"], r.VectorSearchEndpoints), collectResourceMap(descriptions["vector_search_indexes"], r.VectorSearchIndexes), collectResourceMap(descriptions["instance_pools"], r.InstancePools), @@ -164,39 +166,40 @@ func (r *Resources) FindResourceByConfigKey(key string) (ConfigResource, error) // SupportedResources returns a map which keys correspond to the resource key in the bundle configuration. func SupportedResources() map[string]resources.ResourceDescription { return map[string]resources.ResourceDescription{ - "jobs": (&resources.Job{}).ResourceDescription(), - "job_runs": (&resources.JobRun{}).ResourceDescription(), - "pipelines": (&resources.Pipeline{}).ResourceDescription(), - "models": (&resources.MlflowModel{}).ResourceDescription(), - "experiments": (&resources.MlflowExperiment{}).ResourceDescription(), - "instance_pools": (&resources.InstancePool{}).ResourceDescription(), - "model_serving_endpoints": (&resources.ModelServingEndpoint{}).ResourceDescription(), - "registered_models": (&resources.RegisteredModel{}).ResourceDescription(), - "quality_monitors": (&resources.QualityMonitor{}).ResourceDescription(), - "catalogs": (&resources.Catalog{}).ResourceDescription(), - "schemas": (&resources.Schema{}).ResourceDescription(), - "external_locations": (&resources.ExternalLocation{}).ResourceDescription(), - "clusters": (&resources.Cluster{}).ResourceDescription(), - "dashboards": (&resources.Dashboard{}).ResourceDescription(), - "genie_spaces": (&resources.GenieSpace{}).ResourceDescription(), - "volumes": (&resources.Volume{}).ResourceDescription(), - "apps": (&resources.App{}).ResourceDescription(), - "secret_scopes": (&resources.SecretScope{}).ResourceDescription(), - "alerts": (&resources.Alert{}).ResourceDescription(), - "sql_warehouses": (&resources.SqlWarehouse{}).ResourceDescription(), - "database_instances": (&resources.DatabaseInstance{}).ResourceDescription(), - "database_catalogs": (&resources.DatabaseCatalog{}).ResourceDescription(), - "synced_database_tables": (&resources.SyncedDatabaseTable{}).ResourceDescription(), - "postgres_projects": (&resources.PostgresProject{}).ResourceDescription(), - "postgres_branches": (&resources.PostgresBranch{}).ResourceDescription(), - "postgres_endpoints": (&resources.PostgresEndpoint{}).ResourceDescription(), - "postgres_catalogs": (&resources.PostgresCatalog{}).ResourceDescription(), - "postgres_databases": (&resources.PostgresDatabase{}).ResourceDescription(), - "postgres_roles": (&resources.PostgresRole{}).ResourceDescription(), - "postgres_synced_tables": (&resources.PostgresSyncedTable{}).ResourceDescription(), - "vector_search_endpoints": (&resources.VectorSearchEndpoint{}).ResourceDescription(), - "vector_search_indexes": (&resources.VectorSearchIndex{}).ResourceDescription(), - "secrets": (&resources.Secret{}).ResourceDescription(), - "cluster_policies": (&resources.ClusterPolicy{}).ResourceDescription(), + "jobs": (&resources.Job{}).ResourceDescription(), + "job_runs": (&resources.JobRun{}).ResourceDescription(), + "pipelines": (&resources.Pipeline{}).ResourceDescription(), + "models": (&resources.MlflowModel{}).ResourceDescription(), + "experiments": (&resources.MlflowExperiment{}).ResourceDescription(), + "instance_pools": (&resources.InstancePool{}).ResourceDescription(), + "model_serving_endpoints": (&resources.ModelServingEndpoint{}).ResourceDescription(), + "registered_models": (&resources.RegisteredModel{}).ResourceDescription(), + "quality_monitors": (&resources.QualityMonitor{}).ResourceDescription(), + "catalogs": (&resources.Catalog{}).ResourceDescription(), + "schemas": (&resources.Schema{}).ResourceDescription(), + "external_locations": (&resources.ExternalLocation{}).ResourceDescription(), + "clusters": (&resources.Cluster{}).ResourceDescription(), + "dashboards": (&resources.Dashboard{}).ResourceDescription(), + "genie_spaces": (&resources.GenieSpace{}).ResourceDescription(), + "volumes": (&resources.Volume{}).ResourceDescription(), + "apps": (&resources.App{}).ResourceDescription(), + "secret_scopes": (&resources.SecretScope{}).ResourceDescription(), + "alerts": (&resources.Alert{}).ResourceDescription(), + "sql_warehouses": (&resources.SqlWarehouse{}).ResourceDescription(), + "database_instances": (&resources.DatabaseInstance{}).ResourceDescription(), + "database_catalogs": (&resources.DatabaseCatalog{}).ResourceDescription(), + "synced_database_tables": (&resources.SyncedDatabaseTable{}).ResourceDescription(), + "postgres_projects": (&resources.PostgresProject{}).ResourceDescription(), + "postgres_branches": (&resources.PostgresBranch{}).ResourceDescription(), + "postgres_endpoints": (&resources.PostgresEndpoint{}).ResourceDescription(), + "postgres_catalogs": (&resources.PostgresCatalog{}).ResourceDescription(), + "postgres_databases": (&resources.PostgresDatabase{}).ResourceDescription(), + "postgres_roles": (&resources.PostgresRole{}).ResourceDescription(), + "postgres_synced_tables": (&resources.PostgresSyncedTable{}).ResourceDescription(), + "postgres_snapshot_schedules": (&resources.PostgresSnapshotSchedule{}).ResourceDescription(), + "vector_search_endpoints": (&resources.VectorSearchEndpoint{}).ResourceDescription(), + "vector_search_indexes": (&resources.VectorSearchIndex{}).ResourceDescription(), + "secrets": (&resources.Secret{}).ResourceDescription(), + "cluster_policies": (&resources.ClusterPolicy{}).ResourceDescription(), } } diff --git a/bundle/config/resources/postgres_snapshot_schedule.go b/bundle/config/resources/postgres_snapshot_schedule.go new file mode 100644 index 00000000000..374aa55f355 --- /dev/null +++ b/bundle/config/resources/postgres_snapshot_schedule.go @@ -0,0 +1,73 @@ +package resources + +import ( + "context" + "net/url" + + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/marshal" + "github.com/databricks/databricks-sdk-go/service/postgres" +) + +type PostgresSnapshotScheduleConfig struct { + // Branch is the branch whose automatic-snapshot schedule this resource manages. + // Format: "projects/{project_id}/branches/{branch_id}". The schedule's resource + // name (and this resource's ID) is "{branch}/snapshot-schedule". + Branch string `json:"branch"` + + // Schedule is the set of cadences at which automatic snapshots are taken. An + // empty set disables automatic snapshots. Order is not significant; when + // several cadences fire together a single snapshot is taken, retained for the + // longest of their retentions. + Schedule []postgres.ScheduleCadence `json:"schedule,omitempty"` + + // ForceSendFields tracks zero-value top-level fields (branch) for the SDK's + // marshal package. + ForceSendFields []string `json:"-" url:"-"` +} + +func (c *PostgresSnapshotScheduleConfig) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, c) +} + +func (c *PostgresSnapshotScheduleConfig) MarshalJSON() ([]byte, error) { + return marshal.Marshal(c) +} + +type PostgresSnapshotSchedule struct { + BaseResource + PostgresSnapshotScheduleConfig +} + +func (b *PostgresSnapshotSchedule) Exists(ctx context.Context, w *databricks.WorkspaceClient, name string) (bool, error) { + _, err := w.Postgres.GetSnapshotSchedule(ctx, postgres.GetSnapshotScheduleRequest{Name: name}) + if err != nil { + log.Debugf(ctx, "postgres snapshot schedule %s does not exist", name) + return false, err + } + return true, nil +} + +func (b *PostgresSnapshotSchedule) ResourceDescription() ResourceDescription { + return ResourceDescription{ + SingularName: "postgres_snapshot_schedule", + PluralName: "postgres_snapshot_schedules", + SingularTitle: "Postgres snapshot schedule", + PluralTitle: "Postgres snapshot schedules", + } +} + +func (b *PostgresSnapshotSchedule) GetName() string { + // Snapshot schedules don't have a user-visible name field. + return "" +} + +func (b *PostgresSnapshotSchedule) GetURL() string { + // The IDs in the API do not (yet) map to IDs in the web UI. + return "" +} + +func (b *PostgresSnapshotSchedule) InitializeURL(_ url.URL) { + // The IDs in the API do not (yet) map to IDs in the web UI. +} diff --git a/bundle/config/resources_test.go b/bundle/config/resources_test.go index 7e56f47a64a..dfef773ff88 100644 --- a/bundle/config/resources_test.go +++ b/bundle/config/resources_test.go @@ -128,13 +128,14 @@ func TestBundleResourcePluralNamesResolveInWorkspaceURLs(t *testing.T) { // A job run does have a workspace URL, but it's addressed by two IDs // (job + run) so it can't be expressed as a single-ID pattern here; it's // built in JobRun.InitializeURL via workspaceurls.JobRunURL instead. - "job_runs": true, - "postgres_branches": true, - "postgres_databases": true, - "postgres_endpoints": true, - "postgres_projects": true, - "postgres_roles": true, - "secret_scopes": true, + "job_runs": true, + "postgres_branches": true, + "postgres_databases": true, + "postgres_endpoints": true, + "postgres_projects": true, + "postgres_roles": true, + "postgres_snapshot_schedules": true, + "secret_scopes": true, } supported := SupportedResources() @@ -334,6 +335,13 @@ func TestResourcesBindSupport(t *testing.T) { }, }, }, + PostgresSnapshotSchedules: map[string]*resources.PostgresSnapshotSchedule{ + "my_postgres_snapshot_schedule": { + PostgresSnapshotScheduleConfig: resources.PostgresSnapshotScheduleConfig{ + Branch: "projects/my-postgres-project/branches/my-postgres-branch", + }, + }, + }, VectorSearchEndpoints: map[string]*resources.VectorSearchEndpoint{ "my_vector_search_endpoint": { CreateEndpoint: vectorsearch.CreateEndpoint{ @@ -391,6 +399,7 @@ func TestResourcesBindSupport(t *testing.T) { m.GetMockPostgresAPI().EXPECT().GetDatabase(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockPostgresAPI().EXPECT().GetRole(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockPostgresAPI().EXPECT().GetSyncedTable(mock.Anything, mock.Anything).Return(nil, nil) + m.GetMockPostgresAPI().EXPECT().GetSnapshotSchedule(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockPostgresAPI().EXPECT().GetRole(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockVectorSearchEndpointsAPI().EXPECT().GetEndpoint(mock.Anything, mock.Anything).Return(nil, nil) m.GetMockVectorSearchIndexesAPI().EXPECT().GetIndexByIndexName(mock.Anything, mock.Anything).Return(nil, nil) diff --git a/bundle/direct/dresources/all.go b/bundle/direct/dresources/all.go index 391fb0684d2..d482c8fb722 100644 --- a/bundle/direct/dresources/all.go +++ b/bundle/direct/dresources/all.go @@ -7,40 +7,41 @@ import ( ) var SupportedResources = map[string]any{ - "jobs": (*ResourceJob)(nil), - "job_runs": (*ResourceJobRun)(nil), - "pipelines": (*ResourcePipeline)(nil), - "experiments": (*ResourceExperiment)(nil), - "catalogs": (*ResourceCatalog)(nil), - "schemas": (*ResourceSchema)(nil), - "external_locations": (*ResourceExternalLocation)(nil), - "volumes": (*ResourceVolume)(nil), - "models": (*ResourceMlflowModel)(nil), - "apps": (*ResourceApp)(nil), - "sql_warehouses": (*ResourceSqlWarehouse)(nil), - "database_instances": (*ResourceDatabaseInstance)(nil), - "database_catalogs": (*ResourceDatabaseCatalog)(nil), - "synced_database_tables": (*ResourceSyncedDatabaseTable)(nil), - "postgres_projects": (*ResourcePostgresProject)(nil), - "postgres_branches": (*ResourcePostgresBranch)(nil), - "postgres_endpoints": (*ResourcePostgresEndpoint)(nil), - "postgres_catalogs": (*ResourcePostgresCatalog)(nil), - "postgres_databases": (*ResourcePostgresDatabase)(nil), - "postgres_roles": (*ResourcePostgresRole)(nil), - "postgres_synced_tables": (*ResourcePostgresSyncedTable)(nil), - "alerts": (*ResourceAlert)(nil), - "clusters": (*ResourceCluster)(nil), - "registered_models": (*ResourceRegisteredModel)(nil), - "dashboards": (*ResourceDashboard)(nil), - "genie_spaces": (*ResourceGenieSpace)(nil), - "secret_scopes": (*ResourceSecretScope)(nil), - "model_serving_endpoints": (*ResourceModelServingEndpoint)(nil), - "quality_monitors": (*ResourceQualityMonitor)(nil), - "vector_search_endpoints": (*ResourceVectorSearchEndpoint)(nil), - "vector_search_indexes": (*ResourceVectorSearchIndex)(nil), - "instance_pools": (*ResourceInstancePool)(nil), - "secrets": (*ResourceSecret)(nil), - "cluster_policies": (*ResourceClusterPolicy)(nil), + "jobs": (*ResourceJob)(nil), + "job_runs": (*ResourceJobRun)(nil), + "pipelines": (*ResourcePipeline)(nil), + "experiments": (*ResourceExperiment)(nil), + "catalogs": (*ResourceCatalog)(nil), + "schemas": (*ResourceSchema)(nil), + "external_locations": (*ResourceExternalLocation)(nil), + "volumes": (*ResourceVolume)(nil), + "models": (*ResourceMlflowModel)(nil), + "apps": (*ResourceApp)(nil), + "sql_warehouses": (*ResourceSqlWarehouse)(nil), + "database_instances": (*ResourceDatabaseInstance)(nil), + "database_catalogs": (*ResourceDatabaseCatalog)(nil), + "synced_database_tables": (*ResourceSyncedDatabaseTable)(nil), + "postgres_projects": (*ResourcePostgresProject)(nil), + "postgres_branches": (*ResourcePostgresBranch)(nil), + "postgres_endpoints": (*ResourcePostgresEndpoint)(nil), + "postgres_catalogs": (*ResourcePostgresCatalog)(nil), + "postgres_databases": (*ResourcePostgresDatabase)(nil), + "postgres_roles": (*ResourcePostgresRole)(nil), + "postgres_synced_tables": (*ResourcePostgresSyncedTable)(nil), + "postgres_snapshot_schedules": (*ResourcePostgresSnapshotSchedule)(nil), + "alerts": (*ResourceAlert)(nil), + "clusters": (*ResourceCluster)(nil), + "registered_models": (*ResourceRegisteredModel)(nil), + "dashboards": (*ResourceDashboard)(nil), + "genie_spaces": (*ResourceGenieSpace)(nil), + "secret_scopes": (*ResourceSecretScope)(nil), + "model_serving_endpoints": (*ResourceModelServingEndpoint)(nil), + "quality_monitors": (*ResourceQualityMonitor)(nil), + "vector_search_endpoints": (*ResourceVectorSearchEndpoint)(nil), + "vector_search_indexes": (*ResourceVectorSearchIndex)(nil), + "instance_pools": (*ResourceInstancePool)(nil), + "secrets": (*ResourceSecret)(nil), + "cluster_policies": (*ResourceClusterPolicy)(nil), // Permissions "jobs.permissions": (*ResourcePermissions)(nil), diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 36eaa3e27df..d385a8e7cdf 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -792,6 +792,38 @@ var testDeps = map[string]prepareWorkspace{ }, nil }, + "postgres_snapshot_schedules": func(ctx context.Context, client *databricks.WorkspaceClient) (any, error) { + // Create parent project first + _, err := client.Postgres.CreateProject(ctx, postgres.CreateProjectRequest{ + ProjectId: "test-project-for-snapshot-schedule", + Project: postgres.Project{ + Spec: &postgres.ProjectSpec{ + DisplayName: "Test Project for Snapshot Schedule", + PgVersion: 16, + }, + }, + }) + if err != nil { + return nil, err + } + + // Create parent branch + _, err = client.Postgres.CreateBranch(ctx, postgres.CreateBranchRequest{ + Parent: "projects/test-project-for-snapshot-schedule", + BranchId: "test-branch-for-snapshot-schedule", + Branch: postgres.Branch{}, + }) + if err != nil { + return nil, err + } + + return &resources.PostgresSnapshotSchedule{ + PostgresSnapshotScheduleConfig: resources.PostgresSnapshotScheduleConfig{ + Branch: "projects/test-project-for-snapshot-schedule/branches/test-branch-for-snapshot-schedule", + }, + }, nil + }, + "postgres_endpoints": func(ctx context.Context, client *databricks.WorkspaceClient) (any, error) { // Create parent project first _, err := client.Postgres.CreateProject(ctx, postgres.CreateProjectRequest{ @@ -1129,7 +1161,10 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W require.NoError(t, err) } - deleteIsNoop := strings.HasSuffix(group, "permissions") || strings.HasSuffix(group, "grants") + // postgres_snapshot_schedules has no delete endpoint: DoDelete disables the + // schedule by setting an empty cadence set, and the schedule remains readable + // (it is intrinsic to the branch), so DoRead still succeeds afterwards. + deleteIsNoop := strings.HasSuffix(group, "permissions") || strings.HasSuffix(group, "grants") || group == "postgres_snapshot_schedules" // Apps DoDelete is fire-and-forget: the API returns success while the app // sits in DELETING state for up to ~20 minutes before the record is removed. // A GET on the DELETING app returns the app, not 404 -- the testserver diff --git a/bundle/direct/dresources/apitypes.generated.yml b/bundle/direct/dresources/apitypes.generated.yml index ec2e3c2519c..ebb50c43c49 100644 --- a/bundle/direct/dresources/apitypes.generated.yml +++ b/bundle/direct/dresources/apitypes.generated.yml @@ -46,6 +46,8 @@ postgres_projects: postgres.ProjectStatus postgres_roles: postgres.RoleRoleStatus +postgres_snapshot_schedules: postgres.UpdateBranchRequest + postgres_synced_tables: postgres.SyncedTableSyncedTableSpec quality_monitors: catalog.CreateMonitor diff --git a/bundle/direct/dresources/postgres_snapshot_schedule.go b/bundle/direct/dresources/postgres_snapshot_schedule.go new file mode 100644 index 00000000000..3f73e8c729f --- /dev/null +++ b/bundle/direct/dresources/postgres_snapshot_schedule.go @@ -0,0 +1,138 @@ +package dresources + +import ( + "context" + "strings" + + "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/common/types/fieldmask" + "github.com/databricks/databricks-sdk-go/marshal" + "github.com/databricks/databricks-sdk-go/service/postgres" +) + +// snapshotScheduleSuffix is the final segment of a snapshot schedule's resource +// name: "projects/{project_id}/branches/{branch_id}/snapshot-schedule". +const snapshotScheduleSuffix = "/snapshot-schedule" + +// snapshotScheduleName returns the schedule's resource name for a branch. It +// trims any trailing slash on the branch name (e.g. a user-supplied +// "projects/p/branches/b/") so the suffix is not doubled up. +func snapshotScheduleName(branch string) string { + return strings.TrimRight(branch, "/") + snapshotScheduleSuffix +} + +// PostgresSnapshotScheduleRemote is the return type for DoRead. It carries all +// paths present in StateType (branch, schedule) so drift detection works, plus +// the schedule's resource name. +type PostgresSnapshotScheduleRemote struct { + Branch string `json:"branch,omitempty"` + Schedule []postgres.ScheduleCadence `json:"schedule,omitempty"` + Name string `json:"name,omitempty"` +} + +func (s *PostgresSnapshotScheduleRemote) UnmarshalJSON(b []byte) error { + return marshal.Unmarshal(b, s) +} + +func (s PostgresSnapshotScheduleRemote) MarshalJSON() ([]byte, error) { + return marshal.Marshal(s) +} + +type ResourcePostgresSnapshotSchedule struct { + client *databricks.WorkspaceClient +} + +type PostgresSnapshotScheduleState = resources.PostgresSnapshotScheduleConfig + +func (*ResourcePostgresSnapshotSchedule) New(client *databricks.WorkspaceClient) *ResourcePostgresSnapshotSchedule { + return &ResourcePostgresSnapshotSchedule{client: client} +} + +func (*ResourcePostgresSnapshotSchedule) PrepareState(input *resources.PostgresSnapshotSchedule) *PostgresSnapshotScheduleState { + return &PostgresSnapshotScheduleState{ + Branch: input.Branch, + Schedule: input.Schedule, + ForceSendFields: input.ForceSendFields, + } +} + +func (*ResourcePostgresSnapshotSchedule) RemapState(remote *PostgresSnapshotScheduleRemote) *PostgresSnapshotScheduleState { + return &PostgresSnapshotScheduleState{ + Branch: remote.Branch, + Schedule: remote.Schedule, + ForceSendFields: nil, + } +} + +// makePostgresSnapshotScheduleRemote converts the SDK SnapshotSchedule into the +// remote shape. The API addresses the schedule by "{branch}/snapshot-schedule"; +// branch is derived by stripping that suffix so it participates in drift detection. +func makePostgresSnapshotScheduleRemote(schedule *postgres.SnapshotSchedule) *PostgresSnapshotScheduleRemote { + return &PostgresSnapshotScheduleRemote{ + Branch: strings.TrimSuffix(schedule.Name, snapshotScheduleSuffix), + Schedule: schedule.Schedule, + Name: schedule.Name, + } +} + +func (r *ResourcePostgresSnapshotSchedule) DoRead(ctx context.Context, id string) (*PostgresSnapshotScheduleRemote, error) { + schedule, err := r.client.Postgres.GetSnapshotSchedule(ctx, postgres.GetSnapshotScheduleRequest{Name: id}) + if err != nil { + return nil, err + } + return makePostgresSnapshotScheduleRemote(schedule), nil +} + +// updateSnapshotSchedule sets the branch's schedule to the given cadences and +// waits for the long-running operation to complete. It is the single API call +// behind create, update, and delete: there is no Create/DeleteSnapshotSchedule +// endpoint, so the schedule is managed entirely through UpdateSnapshotSchedule. +// schedule is the only updatable path, so a static mask is used. +func (r *ResourcePostgresSnapshotSchedule) updateSnapshotSchedule(ctx context.Context, name string, cadences []postgres.ScheduleCadence) (*postgres.SnapshotSchedule, error) { + waiter, err := r.client.Postgres.UpdateSnapshotSchedule(ctx, postgres.UpdateSnapshotScheduleRequest{ + Name: name, + SnapshotSchedule: postgres.SnapshotSchedule{ + Schedule: cadences, + + // Name is carried in the request's Name field, not the body. + Name: "", + ForceSendFields: nil, + }, + UpdateMask: fieldmask.FieldMask{ + Paths: []string{"schedule"}, + }, + }) + if err != nil { + return nil, err + } + return waiter.Wait(ctx) +} + +func (r *ResourcePostgresSnapshotSchedule) DoCreate(ctx context.Context, config *PostgresSnapshotScheduleState) (string, *PostgresSnapshotScheduleRemote, error) { + // The schedule exists implicitly for every branch; "creating" the resource + // means setting its cadences via UpdateSnapshotSchedule. + result, err := r.updateSnapshotSchedule(ctx, snapshotScheduleName(config.Branch), config.Schedule) + if err != nil { + return "", nil, err + } + remote := makePostgresSnapshotScheduleRemote(result) + return remote.Name, remote, nil +} + +func (r *ResourcePostgresSnapshotSchedule) DoUpdate(ctx context.Context, id string, config *PostgresSnapshotScheduleState, _ *PlanEntry) (*PostgresSnapshotScheduleRemote, error) { + result, err := r.updateSnapshotSchedule(ctx, id, config.Schedule) + if err != nil { + return nil, err + } + return makePostgresSnapshotScheduleRemote(result), nil +} + +func (r *ResourcePostgresSnapshotSchedule) DoDelete(ctx context.Context, id string, _ *PostgresSnapshotScheduleState) error { + // There is no DeleteSnapshotSchedule endpoint. DoDelete fires whenever the + // resource leaves the desired state (removed from config, or bundle destroy), + // so disable automatic snapshots by setting an empty cadence set — otherwise + // the branch would keep taking snapshots after the resource is gone. + _, err := r.updateSnapshotSchedule(ctx, id, nil) + return err +} diff --git a/bundle/direct/dresources/postgres_snapshot_schedule_test.go b/bundle/direct/dresources/postgres_snapshot_schedule_test.go new file mode 100644 index 00000000000..261ceba17ad --- /dev/null +++ b/bundle/direct/dresources/postgres_snapshot_schedule_test.go @@ -0,0 +1,19 @@ +package dresources + +import "testing" + +func TestSnapshotScheduleName(t *testing.T) { + const branch = "projects/p/branches/b" + want := branch + "/snapshot-schedule" + + cases := []string{ + branch, + branch + "/", + branch + "//", + } + for _, in := range cases { + if got := snapshotScheduleName(in); got != want { + t.Errorf("snapshotScheduleName(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/bundle/direct/dresources/resources.generated.yml b/bundle/direct/dresources/resources.generated.yml index 264ba81eca1..85a0466d499 100644 --- a/bundle/direct/dresources/resources.generated.yml +++ b/bundle/direct/dresources/resources.generated.yml @@ -370,6 +370,8 @@ resources: - field: postgres_role reason: spec:input_only + # postgres_snapshot_schedules: no api field behaviors + postgres_synced_tables: ignore_remote_changes: diff --git a/bundle/direct/dresources/resources.yml b/bundle/direct/dresources/resources.yml index 07eab3b6894..bfbc956bd1d 100644 --- a/bundle/direct/dresources/resources.yml +++ b/bundle/direct/dresources/resources.yml @@ -789,6 +789,14 @@ resources: - field: identity_type reason: immutable + postgres_snapshot_schedules: + provided_id_fields: + # branch composes the schedule's hierarchical name + # ("{branch}/snapshot-schedule"); changing it targets a different branch's + # schedule, so it recreates (delete + create). + - field: branch + reason: id_field + vector_search_endpoints: provided_id_fields: # The endpoint API has no rename; the endpoint is fetched by name. diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index e47f89c505a..5de96889db9 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -1575,6 +1575,19 @@ resources: "role_id": "description": |- The user-specified role ID; becomes the final component of the role's resource name. Must be 4-63 characters, lowercase letters, numbers, and hyphens (RFC 1123). + "postgres_snapshot_schedules": + "description": |- + The Postgres snapshot schedule definitions for the bundle, where each key is the name of the snapshot schedule. Each entry configures the automatic-snapshot cadences for a branch of a Lakebase Autoscaling project. + "$fields": + "branch": + "description": |- + The branch whose automatic-snapshot schedule this manages. Format: projects/{project_id}/branches/{branch_id} + "lifecycle": + "description": |- + Settings that control the deployment lifecycle of the resource, such as preventing it from being destroyed. + "schedule": + "description": |- + The cadences at which automatic snapshots are taken. An empty set disables automatic snapshots. When several cadences fire together, one snapshot is taken and retained for the longest of their retentions. "postgres_synced_tables": "description": |- The Postgres synced table definitions for the bundle, where each key is the name of the synced table. Each entry continuously replicates a Unity Catalog Delta source table into a Postgres table on a Lakebase Autoscaling instance. diff --git a/bundle/internal/validation/generated/enum_fields.go b/bundle/internal/validation/generated/enum_fields.go index e65f7ea4348..cb3a8f085de 100644 --- a/bundle/internal/validation/generated/enum_fields.go +++ b/bundle/internal/validation/generated/enum_fields.go @@ -238,6 +238,8 @@ var EnumFields = map[string][]string{ "resources.postgres_roles.*.identity_type": {"GROUP", "SERVICE_PRINCIPAL", "USER"}, "resources.postgres_roles.*.membership_roles[*]": {"DATABRICKS_SUPERUSER"}, + "resources.postgres_snapshot_schedules.*.schedule[*].weekly_schedule.day_of_week": {"FRIDAY", "MONDAY", "SATURDAY", "SUNDAY", "THURSDAY", "TUESDAY", "WEDNESDAY"}, + "resources.postgres_synced_tables.*.extra_columns[*].maintenance": {"STORED_GENERATED"}, "resources.postgres_synced_tables.*.new_pipeline_spec.pipeline_channel": {"CURRENT", "PREVIEW"}, "resources.postgres_synced_tables.*.scheduling_policy": {"CONTINUOUS", "SNAPSHOT", "TRIGGERED"}, diff --git a/bundle/internal/validation/generated/required_fields.go b/bundle/internal/validation/generated/required_fields.go index fcdd9b11ab9..7a8a027a10d 100644 --- a/bundle/internal/validation/generated/required_fields.go +++ b/bundle/internal/validation/generated/required_fields.go @@ -269,6 +269,11 @@ var RequiredFields = map[string][]string{ "resources.postgres_roles.*": {"role_id", "parent"}, + "resources.postgres_snapshot_schedules.*": {"branch"}, + "resources.postgres_snapshot_schedules.*.schedule[*]": {"retention"}, + "resources.postgres_snapshot_schedules.*.schedule[*].monthly_schedule": {"day"}, + "resources.postgres_snapshot_schedules.*.schedule[*].weekly_schedule": {"day_of_week"}, + "resources.postgres_synced_tables.*": {"synced_table_id"}, "resources.postgres_synced_tables.*.extra_columns[*]": {"column_name", "column_type"}, "resources.postgres_synced_tables.*.type_overrides[*]": {"column_name", "pg_type"}, diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index 5967696cb91..ec77bff0bf3 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -2268,6 +2268,35 @@ } ] }, + "resources.PostgresSnapshotSchedule": { + "oneOf": [ + { + "type": "object", + "properties": { + "branch": { + "description": "The branch whose automatic-snapshot schedule this manages. Format: projects/{project_id}/branches/{branch_id}", + "$ref": "#/$defs/string" + }, + "lifecycle": { + "description": "Settings that control the deployment lifecycle of the resource, such as preventing it from being destroyed.", + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" + }, + "schedule": { + "description": "The cadences at which automatic snapshots are taken. An empty set disables automatic snapshots. When several cadences fire together, one snapshot is taken and retained for the longest of their retentions.", + "$ref": "#/$defs/slice/github.com/databricks/databricks-sdk-go/service/postgres.ScheduleCadence" + } + }, + "additionalProperties": false, + "required": [ + "branch" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.PostgresSyncedTable": { "oneOf": [ { @@ -3581,6 +3610,10 @@ "description": "The Postgres role definitions for the bundle, where each key is the name of the role. Each entry defines a role on a Lakebase Autoscaling branch, optionally backed by a Databricks identity.", "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresRole" }, + "postgres_snapshot_schedules": { + "description": "The Postgres snapshot schedule definitions for the bundle, where each key is the name of the snapshot schedule. Each entry configures the automatic-snapshot cadences for a branch of a Lakebase Autoscaling project.", + "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresSnapshotSchedule" + }, "postgres_synced_tables": { "description": "The Postgres synced table definitions for the bundle, where each key is the name of the synced table. Each entry continuously replicates a Unity Catalog Delta source table into a Postgres table on a Lakebase Autoscaling instance.", "$ref": "#/$defs/map/github.com/databricks/cli/bundle/config/resources.PostgresSyncedTable" @@ -12736,6 +12769,57 @@ } ] }, + "postgres.DailySchedule": { + "oneOf": [ + { + "type": "object", + "description": "Take a snapshot once per day, at the configured hour.", + "properties": { + "hour": { + "description": "[Private Preview] The hour of the day, in UTC, at which to take the snapshot, in [0, 23].", + "$ref": "#/$defs/int", + "x-databricks-launch-stage": "PRIVATE_PREVIEW", + "doNotSuggest": true + } + }, + "additionalProperties": false + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, + "postgres.DayOfWeek": { + "oneOf": [ + { + "type": "string", + "description": "The day of the week on which a weekly snapshot is taken.", + "enum": [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY" + ], + "enumDescriptions": [ + "[Private Preview]", + "[Private Preview]", + "[Private Preview]", + "[Private Preview]", + "[Private Preview]", + "[Private Preview]", + "[Private Preview]" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "postgres.EndpointGroupSpec": { "oneOf": [ { @@ -12801,6 +12885,36 @@ } ] }, + "postgres.MonthlySchedule": { + "oneOf": [ + { + "type": "object", + "description": "Take a snapshot once per month, on the configured day at the configured hour.", + "properties": { + "day": { + "description": "[Private Preview] The day of the month on which to take the snapshot, in [1, 31]. In shorter\nmonths the snapshot is taken on the last day instead (day 31 runs on Feb 28\nor 29, and on Apr 30), so every month gets exactly one snapshot.", + "$ref": "#/$defs/int", + "x-databricks-launch-stage": "PRIVATE_PREVIEW", + "doNotSuggest": true + }, + "hour": { + "description": "[Private Preview] The hour of the day, in UTC, at which to take the snapshot, in [0, 23].", + "$ref": "#/$defs/int", + "x-databricks-launch-stage": "PRIVATE_PREVIEW", + "doNotSuggest": true + } + }, + "additionalProperties": false, + "required": [ + "day" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "postgres.NewPipelineSpec": { "oneOf": [ { @@ -12986,6 +13100,48 @@ } ] }, + "postgres.ScheduleCadence": { + "oneOf": [ + { + "type": "object", + "description": "One cadence at which automatic snapshots are taken.", + "properties": { + "daily_schedule": { + "description": "[Private Preview] Take a snapshot once per day.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.DailySchedule", + "x-databricks-launch-stage": "PRIVATE_PREVIEW", + "doNotSuggest": true + }, + "monthly_schedule": { + "description": "[Private Preview] Take a snapshot once per month.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.MonthlySchedule", + "x-databricks-launch-stage": "PRIVATE_PREVIEW", + "doNotSuggest": true + }, + "retention": { + "description": "[Private Preview] How long snapshots from this cadence are kept before automatic deletion.\nMust be at least 1 hour. Applied when a snapshot is taken; not retroactive,\nso changing it affects only later snapshots.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/common/types/duration.Duration", + "x-databricks-launch-stage": "PRIVATE_PREVIEW", + "doNotSuggest": true + }, + "weekly_schedule": { + "description": "[Private Preview] Take a snapshot once per week.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.WeeklySchedule", + "x-databricks-launch-stage": "PRIVATE_PREVIEW", + "doNotSuggest": true + } + }, + "additionalProperties": false, + "required": [ + "retention" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "postgres.SyncedTableSyncedTableSpecExtraColumn": { "oneOf": [ { @@ -13112,6 +13268,36 @@ } ] }, + "postgres.WeeklySchedule": { + "oneOf": [ + { + "type": "object", + "description": "Take a snapshot once per week, on the configured day at the configured hour.", + "properties": { + "day_of_week": { + "description": "[Private Preview] The day of the week on which to take the snapshot.", + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.DayOfWeek", + "x-databricks-launch-stage": "PRIVATE_PREVIEW", + "doNotSuggest": true + }, + "hour": { + "description": "[Private Preview] The hour of the day, in UTC, at which to take the snapshot, in [0, 23].", + "$ref": "#/$defs/int", + "x-databricks-launch-stage": "PRIVATE_PREVIEW", + "doNotSuggest": true + } + }, + "additionalProperties": false, + "required": [ + "day_of_week" + ] + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "serving.Ai21LabsConfig": { "oneOf": [ { @@ -15506,6 +15692,20 @@ } ] }, + "resources.PostgresSnapshotSchedule": { + "oneOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.PostgresSnapshotSchedule" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "resources.PostgresSyncedTable": { "oneOf": [ { @@ -16573,6 +16773,20 @@ } ] }, + "postgres.ScheduleCadence": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/postgres.ScheduleCadence" + } + }, + { + "type": "string", + "pattern": "\\$\\{(var(\\._*\\p{L}+([-_]*[\\p{L}\\p{N}]+)*(\\[[0-9]+\\])*)+)\\}" + } + ] + }, "postgres.SyncedTableSyncedTableSpecExtraColumn": { "oneOf": [ { diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 2f077b1b631..d840a8beae2 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -229,14 +229,15 @@ type FakeWorkspace struct { DatabaseCatalogs map[string]database.DatabaseCatalog SyncedDatabaseTables map[string]database.SyncedDatabaseTable - PostgresProjects map[string]postgres.Project - PostgresBranches map[string]postgres.Branch - PostgresCatalogs map[string]postgres.Catalog - PostgresDatabases map[string]postgres.Database - PostgresEndpoints map[string]postgres.Endpoint - PostgresRoles map[string]postgres.Role - PostgresSyncedTables map[string]postgres.SyncedTable - PostgresOperations map[string]postgres.Operation + PostgresProjects map[string]postgres.Project + PostgresBranches map[string]postgres.Branch + PostgresCatalogs map[string]postgres.Catalog + PostgresDatabases map[string]postgres.Database + PostgresEndpoints map[string]postgres.Endpoint + PostgresRoles map[string]postgres.Role + PostgresSyncedTables map[string]postgres.SyncedTable + PostgresSnapshotSchedules map[string]postgres.SnapshotSchedule + PostgresOperations map[string]postgres.Operation // Branches and endpoints that the server provisioned implicitly together // with their parent (e.g. the production branch on a new project, or the @@ -492,6 +493,7 @@ func NewFakeWorkspace(url, token string) *FakeWorkspace { PostgresEndpoints: map[string]postgres.Endpoint{}, PostgresRoles: map[string]postgres.Role{}, PostgresSyncedTables: map[string]postgres.SyncedTable{}, + PostgresSnapshotSchedules: map[string]postgres.SnapshotSchedule{}, PostgresOperations: map[string]postgres.Operation{}, postgresImplicitBranches: map[string]bool{}, postgresImplicitEndpoints: map[string]bool{}, diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index 8c44d97eae7..8fe25f2b4c5 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -1089,6 +1089,11 @@ func AddDefaultHandlers(server *Server) { return req.Workspace.PostgresOperationGet(name) }) + server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/snapshot-schedule/operations/{operation_id}", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/snapshot-schedule/operations/" + req.Vars["operation_id"] + return req.Workspace.PostgresOperationGet(name) + }) + // Postgres Projects: server.Handle("POST", "/api/2.0/postgres/projects", func(req Request) any { projectID := req.URL.Query().Get("project_id") @@ -1142,6 +1147,17 @@ func AddDefaultHandlers(server *Server) { return req.Workspace.PostgresBranchDelete(name) }) + // Postgres Snapshot Schedules (a per-branch singleton; no create/delete): + server.Handle("GET", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/snapshot-schedule", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/snapshot-schedule" + return req.Workspace.PostgresSnapshotScheduleGet(name) + }) + + server.Handle("PATCH", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/snapshot-schedule", func(req Request) any { + name := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] + "/snapshot-schedule" + return req.Workspace.PostgresSnapshotScheduleUpdate(req, name) + }) + // Postgres Endpoints: server.Handle("POST", "/api/2.0/postgres/projects/{project_id}/branches/{branch_id}/endpoints", func(req Request) any { parent := "projects/" + req.Vars["project_id"] + "/branches/" + req.Vars["branch_id"] diff --git a/libs/testserver/postgres.go b/libs/testserver/postgres.go index ebc402fce07..d2e9c541567 100644 --- a/libs/testserver/postgres.go +++ b/libs/testserver/postgres.go @@ -516,6 +516,65 @@ func (s *FakeWorkspace) PostgresBranchDelete(name string) Response { } } +var snapshotScheduleUpdateMaskPaths = []string{"schedule"} + +// PostgresSnapshotScheduleGet retrieves a branch's snapshot schedule. The +// schedule is intrinsic to the branch: for a branch that never had one set, the +// API returns an empty schedule rather than 404, so the fake mirrors that. +func (s *FakeWorkspace) PostgresSnapshotScheduleGet(name string) Response { + defer s.LockUnlock()() + + branchName := strings.TrimSuffix(name, "/snapshot-schedule") + if _, exists := s.PostgresBranches[branchName]; !exists { + return postgresNotFoundResponse("branch") + } + + schedule, exists := s.PostgresSnapshotSchedules[name] + if !exists { + schedule = postgres.SnapshotSchedule{Name: name} + } + + return Response{ + Body: schedule, + } +} + +// PostgresSnapshotScheduleUpdate sets a branch's snapshot schedule. There is no +// create/delete endpoint; the schedule is managed entirely through this update. +// An empty schedule set disables automatic snapshots. +func (s *FakeWorkspace) PostgresSnapshotScheduleUpdate(req Request, name string) Response { + if resp := validateUpdateMask(req, snapshotScheduleUpdateMaskPaths); resp != nil { + return *resp + } + + defer s.LockUnlock()() + + branchName := strings.TrimSuffix(name, "/snapshot-schedule") + if _, exists := s.PostgresBranches[branchName]; !exists { + return postgresNotFoundResponse("branch") + } + + var updateSchedule postgres.SnapshotSchedule + if len(req.Body) > 0 { + if err := json.Unmarshal(req.Body, &updateSchedule); err != nil { + return Response{ + StatusCode: 400, + Body: fmt.Sprintf("cannot unmarshal request body: %v", err), + } + } + } + + schedule := postgres.SnapshotSchedule{ + Name: name, + Schedule: updateSchedule.Schedule, + } + s.PostgresSnapshotSchedules[name] = schedule + + return Response{ + Body: s.createOperationLocked(name, schedule), + } +} + // PostgresEndpointCreate creates a new postgres endpoint. // // When replaceExisting is true, an existing endpoint with the same ID is updated @@ -1472,6 +1531,8 @@ func (s *FakeWorkspace) createOperationLocked(resourceName string, response any) resourceType = "Catalog" case strings.HasPrefix(resourceName, "synced_tables/"): resourceType = "SyncedTable" + case strings.HasSuffix(resourceName, "/snapshot-schedule"): + resourceType = "SnapshotSchedule" case strings.Contains(resourceName, "/endpoints/"): resourceType = "Endpoint" case strings.Contains(resourceName, "/databases/"):