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/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 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 {