Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions acceptance/bundle/generate/alert/alert.json.tmpl
Original file line number Diff line number Diff line change
@@ -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"
}
}
2 changes: 2 additions & 0 deletions acceptance/bundle/generate/alert/databricks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
bundle:
name: alert-generate
5 changes: 5 additions & 0 deletions acceptance/bundle/generate/alert/out.test.toml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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"
]
}
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions acceptance/bundle/generate/alert/output.txt
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions acceptance/bundle/generate/alert/script
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions acceptance/bundle/generate/alert/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Cloud = true
Local = false

[Env]
# MSYS2 automatically converts absolute paths like /Users/$username/$UNIQUE_NAME to
# C:/Program Files/Git/Users/$username/UNIQUE_NAME before passing it to the CLI
# Setting this environment variable prevents that conversion on windows.
MSYS_NO_PATHCONV = "1"
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
bundle:
name: test-alert-existing-id-not-found

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

>>> errcode [CLI] bundle generate alert --existing-id f00dcafe
Error: alert with ID f00dcafe not found

Exit code: 1
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions acceptance/bundle/help/bundle-generate/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions bundle/generate/alert.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions cmd/bundle/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
180 changes: 180 additions & 0 deletions cmd/bundle/generate/alert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
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.MarkFlagRequired("existing-id")

// Alias lookup flag that includes the resource type name.
// Included for symmetry with the other generate commands, but we prefer the shorter flag.
cmd.Flags().StringVar(&alertID, "existing-alert-id", "", `ID of the alert to generate configuration for`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

what the difference between these two?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have something similar in dashboards as well, so to keep things consistent:

	// Alias lookup flags that include the resource type name.
	// Included for symmetry with the other generate commands, but we prefer the shorter flags.
	cmd.Flags().StringVar(&d.existingPath, "existing-dashboard-path", "", `workspace path of the dashboard to generate configuration for`)
	cmd.Flags().StringVar(&d.existingID, "existing-dashboard-id", "", `ID of the dashboard to generate configuration for`)
	cmd.Flags().MarkHidden("existing-dashboard-path")
	cmd.Flags().MarkHidden("existing-dashboard-id")

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add a comment clarifying this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have a preferred way then the other is deprecated? In that case, we can skip it for new resources?

Copy link
Contributor Author

@shreyas-goenka shreyas-goenka Jan 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the goal was symmetry (cc: @pietern ). I don't mind it either way. The flag is hidden so users will not be encouraged to use it.

cmd.Flags().MarkHidden("existing-alert-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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could libs/diag/sdk_error.go be useful here to show error in full? or perhaps somewhere at the caller side?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea but I don't think we should do it here. We can solve this in general by modifying the main function and logging the API error details everytime we see an apierr.APIError.

}

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