From 77ed961bc1348afd1e65065a9775422fe49b0b33 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Thu, 18 Jun 2026 15:55:32 +0200 Subject: [PATCH] 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 {