diff --git a/acceptance/bundle/generate/alert/alert.json.tmpl b/acceptance/bundle/generate/alert/alert.json.tmpl new file mode 100644 index 0000000000..6aa6902422 --- /dev/null +++ b/acceptance/bundle/generate/alert/alert.json.tmpl @@ -0,0 +1,21 @@ +{ + "display_name": "test alert", + "parent_path": "/Workspace/test-$UNIQUE_NAME", + "query_text": "SELECT 1\n as value", + "warehouse_id": "$TEST_DEFAULT_WAREHOUSE_ID", + "evaluation": { + "comparison_operator": "GREATER_THAN", + "source": { + "name": "value" + }, + "threshold": { + "value": { + "double_value": 0.0 + } + } + }, + "schedule": { + "quartz_cron_schedule": "0 0 * * * ?", + "timezone_id": "UTC" + } +} diff --git a/acceptance/bundle/generate/alert/databricks.yml b/acceptance/bundle/generate/alert/databricks.yml new file mode 100644 index 0000000000..8f5d694663 --- /dev/null +++ b/acceptance/bundle/generate/alert/databricks.yml @@ -0,0 +1,2 @@ +bundle: + name: alert-generate diff --git a/acceptance/bundle/generate/alert/out.test.toml b/acceptance/bundle/generate/alert/out.test.toml new file mode 100644 index 0000000000..01ed6822af --- /dev/null +++ b/acceptance/bundle/generate/alert/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = true + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/generate/alert/out/alert/test_alert.dbalert.json b/acceptance/bundle/generate/alert/out/alert/test_alert.dbalert.json new file mode 100644 index 0000000000..a4ea8a85c4 --- /dev/null +++ b/acceptance/bundle/generate/alert/out/alert/test_alert.dbalert.json @@ -0,0 +1,22 @@ +{ + "evaluation": { + "source": { + "name": "value" + }, + "comparison_operator": "GREATER_THAN", + "threshold": { + "value": { + "double_value": 0.0 + } + }, + "notification": {} + }, + "schedule": { + "quartz_cron_schedule": "0 0 * * * ?", + "timezone_id": "UTC" + }, + "query_lines": [ + "SELECT 1", + " as value" + ] +} diff --git a/acceptance/bundle/generate/alert/out/resource/test_alert.alert.yml b/acceptance/bundle/generate/alert/out/resource/test_alert.alert.yml new file mode 100644 index 0000000000..0a4dced932 --- /dev/null +++ b/acceptance/bundle/generate/alert/out/resource/test_alert.alert.yml @@ -0,0 +1,6 @@ +resources: + alerts: + test_alert: + display_name: "test alert" + warehouse_id: [TEST_DEFAULT_WAREHOUSE_ID] + file_path: ../alert/test_alert.dbalert.json diff --git a/acceptance/bundle/generate/alert/output.txt b/acceptance/bundle/generate/alert/output.txt new file mode 100644 index 0000000000..9d8a9f77eb --- /dev/null +++ b/acceptance/bundle/generate/alert/output.txt @@ -0,0 +1,6 @@ + +>>> [CLI] workspace mkdirs /Workspace/test-[UNIQUE_NAME] + +>>> [CLI] bundle generate alert --existing-id [NUMID] --source-dir out/alert --config-dir out/resource +Alert configuration successfully saved to [TEST_TMP_DIR]/out/resource/test_alert.alert.yml +Serialized alert definition to [TEST_TMP_DIR]/out/alert/test_alert.dbalert.json diff --git a/acceptance/bundle/generate/alert/script b/acceptance/bundle/generate/alert/script new file mode 100644 index 0000000000..ae40309f98 --- /dev/null +++ b/acceptance/bundle/generate/alert/script @@ -0,0 +1,8 @@ +trace $CLI workspace mkdirs /Workspace/test-$UNIQUE_NAME + +# create an alert to import +envsubst < alert.json.tmpl > alert.json +alert_id=$($CLI alerts-v2 create-alert --json @alert.json | jq -r '.id') +rm alert.json + +trace $CLI bundle generate alert --existing-id $alert_id --source-dir out/alert --config-dir out/resource diff --git a/acceptance/bundle/generate/alert/test.toml b/acceptance/bundle/generate/alert/test.toml new file mode 100644 index 0000000000..1c1fa982aa --- /dev/null +++ b/acceptance/bundle/generate/alert/test.toml @@ -0,0 +1,2 @@ +Cloud = true +Local = false diff --git a/acceptance/bundle/generate/alert_existing_id_not_found/out.test.toml b/acceptance/bundle/generate/alert_existing_id_not_found/out.test.toml new file mode 100644 index 0000000000..d560f1de04 --- /dev/null +++ b/acceptance/bundle/generate/alert_existing_id_not_found/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false + +[EnvMatrix] + DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/generate/alert_existing_id_not_found/output.txt b/acceptance/bundle/generate/alert_existing_id_not_found/output.txt new file mode 100644 index 0000000000..3f841a9279 --- /dev/null +++ b/acceptance/bundle/generate/alert_existing_id_not_found/output.txt @@ -0,0 +1,5 @@ + +>>> errcode [CLI] bundle generate alert --existing-id f00dcafe +Error: alert with ID f00dcafe not found + +Exit code: 1 diff --git a/acceptance/bundle/generate/alert_existing_id_not_found/script b/acceptance/bundle/generate/alert_existing_id_not_found/script new file mode 100644 index 0000000000..85edf127c6 --- /dev/null +++ b/acceptance/bundle/generate/alert_existing_id_not_found/script @@ -0,0 +1,2 @@ +# Test that bundle generate alert fails when the existing ID is not found +trace errcode $CLI bundle generate alert --existing-id f00dcafe diff --git a/acceptance/bundle/help/bundle-generate/output.txt b/acceptance/bundle/help/bundle-generate/output.txt index 9dedd80b39..97e8667ac7 100644 --- a/acceptance/bundle/help/bundle-generate/output.txt +++ b/acceptance/bundle/help/bundle-generate/output.txt @@ -27,6 +27,7 @@ Usage: databricks bundle generate [command] Available Commands: + alert Generate configuration for an alert app Generate bundle configuration for a Databricks app dashboard Generate configuration for a dashboard job Generate bundle configuration for a job diff --git a/acceptance/bundle/refschema/out.fields.txt b/acceptance/bundle/refschema/out.fields.txt index 2394ea7989..5f932d62c1 100644 --- a/acceptance/bundle/refschema/out.fields.txt +++ b/acceptance/bundle/refschema/out.fields.txt @@ -30,6 +30,7 @@ resources.alerts.*.evaluation.threshold.value *sql.AlertV2OperandValue ALL resources.alerts.*.evaluation.threshold.value.bool_value bool ALL resources.alerts.*.evaluation.threshold.value.double_value float64 ALL resources.alerts.*.evaluation.threshold.value.string_value string ALL +resources.alerts.*.file_path string INPUT resources.alerts.*.id string ALL resources.alerts.*.lifecycle resources.Lifecycle INPUT resources.alerts.*.lifecycle.prevent_destroy bool INPUT diff --git a/bundle/config/resources/alerts.go b/bundle/config/resources/alerts.go index 35d361b556..1c2aebdf9a 100644 --- a/bundle/config/resources/alerts.go +++ b/bundle/config/resources/alerts.go @@ -15,6 +15,10 @@ type Alert struct { sql.AlertV2 //nolint AlertV2 also defines Id and URL field with the same json tag "id" and "url" Permissions []AlertPermission `json:"permissions,omitempty"` + + // FilePath points to the local `.dbalert.json` file containing the alert definition. + // This is inlined into the alert during deployment. + FilePath string `json:"file_path,omitempty"` } func (a *Alert) UnmarshalJSON(b []byte) error { diff --git a/bundle/generate/alert.go b/bundle/generate/alert.go new file mode 100644 index 0000000000..3f2428b72e --- /dev/null +++ b/bundle/generate/alert.go @@ -0,0 +1,18 @@ +package generate + +import ( + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/databricks-sdk-go/service/sql" +) + +func ConvertAlertToValue(alert *sql.AlertV2, filePath string) (dyn.Value, error) { + // The majority of fields of the alert struct are present in .dbalert.json file. + // We copy the relevant fields manually. + dv := map[string]dyn.Value{ + "display_name": dyn.NewValue(alert.DisplayName, []dyn.Location{{Line: 1}}), + "warehouse_id": dyn.NewValue(alert.WarehouseId, []dyn.Location{{Line: 2}}), + "file_path": dyn.NewValue(filePath, []dyn.Location{{Line: 3}}), + } + + return dyn.V(dv), nil +} diff --git a/bundle/internal/schema/annotations.yml b/bundle/internal/schema/annotations.yml index 29b2fd03e6..cc1a7e41c0 100644 --- a/bundle/internal/schema/annotations.yml +++ b/bundle/internal/schema/annotations.yml @@ -461,6 +461,9 @@ github.com/databricks/cli/bundle/config/resources.Alert: "effective_run_as": "description": |- PLACEHOLDER + "file_path": + "description": |- + PLACEHOLDER "id": "description": |- PLACEHOLDER diff --git a/bundle/schema/jsonschema.json b/bundle/schema/jsonschema.json index bf8dc42ac0..d83cfe0661 100644 --- a/bundle/schema/jsonschema.json +++ b/bundle/schema/jsonschema.json @@ -76,6 +76,9 @@ "evaluation": { "$ref": "#/$defs/github.com/databricks/databricks-sdk-go/service/sql.AlertV2Evaluation" }, + "file_path": { + "$ref": "#/$defs/string" + }, "lifecycle": { "$ref": "#/$defs/github.com/databricks/cli/bundle/config/resources.Lifecycle" }, diff --git a/cmd/bundle/generate.go b/cmd/bundle/generate.go index d282e14b5e..9caf7fa1e3 100644 --- a/cmd/bundle/generate.go +++ b/cmd/bundle/generate.go @@ -38,6 +38,7 @@ Use --bind to automatically bind the generated resource to the existing workspac cmd.AddCommand(generate.NewGenerateJobCommand()) cmd.AddCommand(generate.NewGeneratePipelineCommand()) cmd.AddCommand(generate.NewGenerateDashboardCommand()) + cmd.AddCommand(generate.NewGenerateAlertCommand()) cmd.AddCommand(generate.NewGenerateAppCommand()) cmd.PersistentFlags().StringVar(&key, "key", "", `resource key to use for the generated configuration`) return cmd diff --git a/cmd/bundle/generate/alert.go b/cmd/bundle/generate/alert.go new file mode 100644 index 0000000000..c2881affc1 --- /dev/null +++ b/cmd/bundle/generate/alert.go @@ -0,0 +1,177 @@ +package generate + +import ( + "encoding/base64" + "errors" + "fmt" + "os" + "path" + "path/filepath" + + "github.com/databricks/cli/bundle/generate" + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/yamlsaver" + "github.com/databricks/cli/libs/logdiag" + "github.com/databricks/cli/libs/textutil" + "github.com/databricks/databricks-sdk-go/apierr" + "github.com/databricks/databricks-sdk-go/service/sql" + "github.com/databricks/databricks-sdk-go/service/workspace" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +func NewGenerateAlertCommand() *cobra.Command { + var alertID string + var configDir string + var sourceDir string + var force bool + + cmd := &cobra.Command{ + Use: "alert", + Short: "Generate configuration for an alert", + Long: `Generate bundle configuration for an existing Databricks alert. + +This command downloads an existing SQL alert and creates bundle files +that you can use to deploy the alert to other environments or manage it as code. + +Examples: + # Generate alert configuration by ID + databricks bundle generate alert --existing-id abc123 + + # Specify custom directories for organization + databricks bundle generate alert --existing-id abc123 \ + --key my_alert --config-dir resources --source-dir src + +What gets generated: +- Alert configuration YAML file with settings and a reference to the alert definition +- Alert definition (.dbalert.json) file with the complete alert specification + +After generation, you can deploy this alert to other targets using: + databricks bundle deploy --target staging + databricks bundle deploy --target prod`, + } + + cmd.Flags().StringVar(&alertID, "existing-id", "", `ID of the alert to generate configuration for`) + cmd.Flags().StringVar(&alertID, "existing-alert-id", "", `ID of the alert to generate configuration for`) + cmd.Flags().MarkHidden("existing-alert-id") + cmd.MarkFlagRequired("existing-id") + + cmd.Flags().StringVarP(&configDir, "config-dir", "d", "resources", `directory to write the configuration to`) + cmd.Flags().StringVarP(&sourceDir, "source-dir", "s", "src", `directory to write the alert definition to`) + cmd.Flags().BoolVarP(&force, "force", "f", false, `force overwrite existing files in the output directory`) + + cmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx := logdiag.InitContext(cmd.Context()) + cmd.SetContext(ctx) + + b := root.MustConfigureBundle(cmd) + if b == nil || logdiag.HasError(ctx) { + return root.ErrAlreadyPrinted + } + + w := b.WorkspaceClient() + + // Get alert from Databricks + alert, err := w.AlertsV2.GetAlert(ctx, sql.GetAlertV2Request{Id: alertID}) + if err != nil { + // Check if it's a not found error to provide a better message + var apiErr *apierr.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 404 { + return fmt.Errorf("alert with ID %s not found", alertID) + } + return err + } + + // Calculate paths + alertKey := cmd.Flag("key").Value.String() + if alertKey == "" { + alertKey = textutil.NormalizeString(alert.DisplayName) + } + + // Make paths absolute if they aren't already + if !filepath.IsAbs(configDir) { + configDir = filepath.Join(b.BundleRootPath, configDir) + } + if !filepath.IsAbs(sourceDir) { + sourceDir = filepath.Join(b.BundleRootPath, sourceDir) + } + + // Calculate relative path from config dir to source dir + relativeSourceDir, err := filepath.Rel(configDir, sourceDir) + if err != nil { + return err + } + relativeSourceDir = filepath.ToSlash(relativeSourceDir) + + // Save alert definition to source directory + alertBasename := alertKey + ".dbalert.json" + alertPath := filepath.Join(sourceDir, alertBasename) + + // remote alert path + remoteAlertPath := path.Join(alert.ParentPath, alert.DisplayName+".dbalert.json") + resp, err := w.Workspace.Export(ctx, workspace.ExportRequest{ + Path: remoteAlertPath, + }) + if err != nil { + return err + } + alertJSON, err := base64.StdEncoding.DecodeString(resp.Content) + if err != nil { + return err + } + + // Create source directory if needed + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + return err + } + + // Check if file exists and force flag + if _, err := os.Stat(alertPath); err == nil && !force { + return fmt.Errorf("%s already exists. Use --force to overwrite", alertPath) + } + + // Write alert definition file + if err := os.WriteFile(alertPath, alertJSON, 0o644); err != nil { + return err + } + + // Convert alert to bundle configuration + v, err := generate.ConvertAlertToValue(alert, path.Join(relativeSourceDir, alertBasename)) + if err != nil { + return err + } + + result := map[string]dyn.Value{ + "resources": dyn.V(map[string]dyn.Value{ + "alerts": dyn.V(map[string]dyn.Value{ + alertKey: v, + }), + }), + } + + // Create config directory if needed + if err := os.MkdirAll(configDir, 0o755); err != nil { + return err + } + + // Save configuration file + configPath := filepath.Join(configDir, alertKey+".alert.yml") + saver := yamlsaver.NewSaverWithStyle(map[string]yaml.Style{ + "display_name": yaml.DoubleQuotedStyle, + }) + + err = saver.SaveAsYAML(result, configPath, force) + if err != nil { + return err + } + + cmdio.LogString(ctx, "Alert configuration successfully saved to "+configPath) + cmdio.LogString(ctx, "Serialized alert definition to "+alertPath) + + return nil + } + + return cmd +}