From 77ed961bc1348afd1e65065a9775422fe49b0b33 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Thu, 18 Jun 2026 15:55:32 +0200 Subject: [PATCH 1/3] acc: run alerts/with_file test locally against the testserver fake The alerts/with_file acceptance test deploys an alert and exports the .dbalert.json file the backend materializes at the alert's parent_path, then diffs it against the local copy. The in-process testserver fake did not reproduce this side effect, so the export returned nothing and the test could only run on cloud. Teach the alerts handler to write a .dbalert.json file on create/update (reversing the line-joining in load_dbalert_files.go) and delete it on trash, mirroring the backend. Also fix the workspace export handler to return the SDK's base64 ExportResponse JSON when direct_download is not set; previously it always returned raw bytes, which the SDK's Workspace.Export (used by `databricks workspace export`) could not parse. Byte-fidelity to the backend's exact serialization (float "2.0", forced notify_on_ok, source-first field order) is not reproducible from the parsed AlertV2 via the SDK marshaler, so the committed input file is regenerated to the fake's deterministic output. The resulting output.txt is byte-identical to the prior cloud recording. Co-authored-by: Isaac --- .../alerts/with_file/alert.dbalert.json | 16 ++-- .../resources/alerts/with_file/out.test.toml | 2 +- .../resources/alerts/with_file/test.toml | 2 +- libs/testserver/alerts.go | 82 +++++++++++++++++++ libs/testserver/handlers.go | 15 +++- 5 files changed, 106 insertions(+), 11 deletions(-) diff --git a/acceptance/bundle/resources/alerts/with_file/alert.dbalert.json b/acceptance/bundle/resources/alerts/with_file/alert.dbalert.json index d9690fc3c63..e24850a7668 100644 --- a/acceptance/bundle/resources/alerts/with_file/alert.dbalert.json +++ b/acceptance/bundle/resources/alerts/with_file/alert.dbalert.json @@ -1,20 +1,20 @@ { "custom_summary": "My alert from file", "evaluation": { + "comparison_operator": "EQUAL", + "notification": { + "notify_on_ok": false, + "retrigger_seconds": 1 + }, "source": { - "name": "1", + "aggregation": "MAX", "display": "1", - "aggregation": "MAX" + "name": "1" }, - "comparison_operator": "EQUAL", "threshold": { "value": { - "double_value": 2.0 + "double_value": 2 } - }, - "notification": { - "retrigger_seconds": 1, - "notify_on_ok": false } }, "schedule": { diff --git a/acceptance/bundle/resources/alerts/with_file/out.test.toml b/acceptance/bundle/resources/alerts/with_file/out.test.toml index 650836edeb3..bbc7fcfd1bd 100644 --- a/acceptance/bundle/resources/alerts/with_file/out.test.toml +++ b/acceptance/bundle/resources/alerts/with_file/out.test.toml @@ -1,3 +1,3 @@ -Local = false +Local = true Cloud = true EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/resources/alerts/with_file/test.toml b/acceptance/bundle/resources/alerts/with_file/test.toml index efeaad4b922..fb1dfd4006a 100644 --- a/acceptance/bundle/resources/alerts/with_file/test.toml +++ b/acceptance/bundle/resources/alerts/with_file/test.toml @@ -1,4 +1,4 @@ -Local = false +Local = true Cloud = true RecordRequests = false Ignore = [".databricks"] diff --git a/libs/testserver/alerts.go b/libs/testserver/alerts.go index c5163a056a4..71c751749f9 100644 --- a/libs/testserver/alerts.go +++ b/libs/testserver/alerts.go @@ -4,10 +4,83 @@ import ( "encoding/json" "fmt" "net/http" + "path" + "strings" "github.com/databricks/databricks-sdk-go/service/sql" + "github.com/databricks/databricks-sdk-go/service/workspace" ) +// alertFile mirrors the schema of a .dbalert.json file. It is the inverse of +// bundle/config/mutator/load_dbalert_files.go: API-only fields (display_name, +// warehouse_id) are dropped and the joined query_text/custom_description are +// split back into lines. The field order matches what the backend materializes. +type alertFile struct { + CustomSummary string `json:"custom_summary,omitempty"` + Evaluation *sql.AlertV2Evaluation `json:"evaluation,omitempty"` + Schedule *sql.CronSchedule `json:"schedule,omitempty"` + QueryLines []string `json:"query_lines,omitempty"` + CustomDescriptionLines []string `json:"custom_description_lines,omitempty"` +} + +// alertFilePath returns the workspace path of the .dbalert.json file the backend +// materializes for an alert: /.dbalert.json. +func alertFilePath(alert sql.AlertV2) string { + return path.Join(alert.ParentPath, alert.DisplayName+".dbalert.json") +} + +// splitLines reverses the line-joining done in load_dbalert_files.go: each line +// is terminated by "\n", so splitting drops the trailing empty element. +func splitLines(s string) []string { + if s == "" { + return nil + } + return strings.Split(strings.TrimSuffix(s, "\n"), "\n") +} + +// writeAlertFile materializes the .dbalert.json file for an alert. On real cloud +// the backend writes this file as a side effect of alert creation/update, which +// the `workspace export` round-trip and `bundle generate alert` rely on. +func (s *FakeWorkspace) writeAlertFile(alert sql.AlertV2) error { + if alert.ParentPath == "" || alert.DisplayName == "" { + return nil + } + + evaluation := alert.Evaluation + if evaluation.Notification != nil { + // The backend always serializes notify_on_ok in the file, even when + // false; the SDK marshaler would otherwise drop the zero value. + notification := *evaluation.Notification + notification.ForceSendFields = append(notification.ForceSendFields, "NotifyOnOk") + evaluation.Notification = ¬ification + } + + af := alertFile{ + CustomSummary: alert.CustomSummary, + Evaluation: &evaluation, + Schedule: &alert.Schedule, + QueryLines: splitLines(alert.QueryText), + CustomDescriptionLines: splitLines(alert.CustomDescription), + } + + data, err := json.MarshalIndent(af, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + + filePath := alertFilePath(alert) + s.files[filePath] = FileEntry{ + Info: workspace.ObjectInfo{ + ObjectType: "FILE", + Path: filePath, + ObjectId: nextID(), + }, + Data: data, + } + return nil +} + func (s *FakeWorkspace) AlertsUpsert(req Request, alertId string) Response { var alert sql.AlertV2 @@ -35,6 +108,13 @@ func (s *FakeWorkspace) AlertsUpsert(req Request, alertId string) Response { alert.LifecycleState = sql.AlertLifecycleStateActive s.Alerts[alertId] = alert + if err := s.writeAlertFile(alert); err != nil { + return Response{ + Body: fmt.Sprintf("internal error: %s", err), + StatusCode: http.StatusInternalServerError, + } + } + return Response{ StatusCode: 200, Body: alert, @@ -51,6 +131,8 @@ func (s *FakeWorkspace) AlertsDelete(alertId string, purge bool) Response { } } + delete(s.files, alertFilePath(alert)) + if purge { delete(s.Alerts, alertId) } else { diff --git a/libs/testserver/handlers.go b/libs/testserver/handlers.go index 8f611a7c7c9..ee56cefe3e6 100644 --- a/libs/testserver/handlers.go +++ b/libs/testserver/handlers.go @@ -94,7 +94,20 @@ func AddDefaultHandlers(server *Server) { server.Handle("GET", "/api/2.0/workspace/export", func(req Request) any { path := req.URL.Query().Get("path") - return req.Workspace.WorkspaceExport(path) + data := req.Workspace.WorkspaceExport(path) + + // The filer reads the raw object body via ?direct_download=true, while + // the SDK's Workspace.Export (used by `databricks workspace export`) + // requests JSON and expects the base64-encoded content field. + if req.URL.Query().Get("direct_download") == "true" { + return data + } + + return Response{ + Body: workspace.ExportResponse{ + Content: base64.StdEncoding.EncodeToString(data), + }, + } }) server.Handle("POST", "/api/2.0/workspace/delete", func(req Request) any { From 61435886544e7149c38fa1f11e7b7e2807957ce5 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 22 Jun 2026 09:54:18 +0200 Subject: [PATCH 2/3] acc: compare alert file canonically so it passes on cloud The backend re-serializes the .dbalert.json when materializing it in the workspace (different key order, and numbers like 2.0 vs 2), so a byte-for-byte diff against the local file fails on cloud. Restore the original alert.dbalert.json and compare canonically (sorted keys, normalized numbers) instead. Verified passing locally and on aws-prod-ucws for both engines. Co-authored-by: Isaac --- .../alerts/with_file/alert.dbalert.json | 16 ++++++++-------- .../bundle/resources/alerts/with_file/output.txt | 2 +- .../bundle/resources/alerts/with_file/script | 10 ++++++++-- .../bundle/resources/alerts/with_file/test.toml | 2 +- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/acceptance/bundle/resources/alerts/with_file/alert.dbalert.json b/acceptance/bundle/resources/alerts/with_file/alert.dbalert.json index e24850a7668..d9690fc3c63 100644 --- a/acceptance/bundle/resources/alerts/with_file/alert.dbalert.json +++ b/acceptance/bundle/resources/alerts/with_file/alert.dbalert.json @@ -1,20 +1,20 @@ { "custom_summary": "My alert from file", "evaluation": { - "comparison_operator": "EQUAL", - "notification": { - "notify_on_ok": false, - "retrigger_seconds": 1 - }, "source": { - "aggregation": "MAX", + "name": "1", "display": "1", - "name": "1" + "aggregation": "MAX" }, + "comparison_operator": "EQUAL", "threshold": { "value": { - "double_value": 2 + "double_value": 2.0 } + }, + "notification": { + "retrigger_seconds": 1, + "notify_on_ok": false } }, "schedule": { diff --git a/acceptance/bundle/resources/alerts/with_file/output.txt b/acceptance/bundle/resources/alerts/with_file/output.txt index 2f89acfae88..18adbb6527d 100644 --- a/acceptance/bundle/resources/alerts/with_file/output.txt +++ b/acceptance/bundle/resources/alerts/with_file/output.txt @@ -70,7 +70,7 @@ Deployment complete! === assert that the uploaded alert file is the same as the local one >>> [CLI] workspace export /Workspace/Users/[USERNAME]/.bundle/alerts-with-file-[UNIQUE_NAME]/default/resources/myalert.dbalert.json ->>> diff exported_alert.dbalert.json alert.dbalert.json --strip-trailing-cr +>>> diff local_normalized.json exported_normalized.json >>> [CLI] alerts-v2 get-alert [ALERT_ID] { diff --git a/acceptance/bundle/resources/alerts/with_file/script b/acceptance/bundle/resources/alerts/with_file/script index 69209ba2d51..d77dcadc44f 100755 --- a/acceptance/bundle/resources/alerts/with_file/script +++ b/acceptance/bundle/resources/alerts/with_file/script @@ -19,8 +19,14 @@ trace $CLI alerts-v2 get-alert $alert_id | jq '{display_name, lifecycle_state, c alert_path=$($CLI bundle summary --output json | jq -r '.resources.alerts.myalert.parent_path')/myalert.dbalert.json title "assert that the uploaded alert file is the same as the local one" trace $CLI workspace export $alert_path > exported_alert.dbalert.json -trace diff exported_alert.dbalert.json alert.dbalert.json --strip-trailing-cr -rm exported_alert.dbalert.json +# The backend re-serializes the alert file when materializing it in the workspace +# (different key order, and numbers like 2.0 vs 2), so compare canonically with +# sorted keys and normalized numbers rather than byte-for-byte. +normalize_json() { jq -S 'walk(if type == "number" then . + 0 else . end)' "$1"; } +normalize_json exported_alert.dbalert.json > exported_normalized.json +normalize_json alert.dbalert.json > local_normalized.json +trace diff local_normalized.json exported_normalized.json +rm exported_alert.dbalert.json exported_normalized.json local_normalized.json trace $CLI alerts-v2 get-alert $alert_id | jq '{display_name, lifecycle_state}' diff --git a/acceptance/bundle/resources/alerts/with_file/test.toml b/acceptance/bundle/resources/alerts/with_file/test.toml index fb1dfd4006a..e3f9d012c08 100644 --- a/acceptance/bundle/resources/alerts/with_file/test.toml +++ b/acceptance/bundle/resources/alerts/with_file/test.toml @@ -1,7 +1,7 @@ Local = true Cloud = true RecordRequests = false -Ignore = [".databricks"] +Ignore = [".databricks", "exported_alert.dbalert.json", "exported_normalized.json", "local_normalized.json"] # Alert tests timeout during bundle deploy (hang at file upload for 50+ minutes). # Use aggressive 5-minute timeout until the issue is resolved. From 8199ad31cc27dfe99ed629cfa62dab5f8d2d8ab5 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 22 Jun 2026 10:02:14 +0200 Subject: [PATCH 3/3] acc: drop redundant Ignore entries for normalized temp files The script already removes the normalized files at the end, so they don't need Ignore entries. Co-authored-by: Isaac --- acceptance/bundle/resources/alerts/with_file/test.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/bundle/resources/alerts/with_file/test.toml b/acceptance/bundle/resources/alerts/with_file/test.toml index e3f9d012c08..fb1dfd4006a 100644 --- a/acceptance/bundle/resources/alerts/with_file/test.toml +++ b/acceptance/bundle/resources/alerts/with_file/test.toml @@ -1,7 +1,7 @@ Local = true Cloud = true RecordRequests = false -Ignore = [".databricks", "exported_alert.dbalert.json", "exported_normalized.json", "local_normalized.json"] +Ignore = [".databricks"] # Alert tests timeout during bundle deploy (hang at file upload for 50+ minutes). # Use aggressive 5-minute timeout until the issue is resolved.