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 {