diff --git a/README.md b/README.md index 8fe84f9..a4ef942 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Datadog CLI -`ddcli` is a read-only Datadog CLI designed for scripts and LLM agents. It keeps the interface narrow and discoverable while returning stable JSON on stdout. +`ddcli` is a Datadog CLI designed for scripts and LLM agents. It keeps the +interface narrow and discoverable while returning stable JSON on stdout. +Most commands are read-only; write commands are explicit and idempotent. ## Auth @@ -22,7 +24,7 @@ For temporary OAuth-style credentials, `DD_ACCESS_TOKEN` or `--access-token` is ## Output -Every read command writes JSON to stdout. Diagnostics and errors go to stderr. +Every command writes JSON to stdout. Diagnostics and errors go to stderr. Default output is wrapped: @@ -57,6 +59,8 @@ ddcli hosts list --filter 'env:prod' --limit 25 ddcli hosts totals ddcli dashboards list --query app --limit 100 ddcli dashboards get abc-def-ghi +ddcli dashboards apply dashboard.json --dry-run +ddcli dashboards apply dashboard.json ddcli apm spans --query 'service:api @http.status_code:500' --from now-15m --to now --limit 25 ddcli appsec blocked-rules summary --from now-7d --limit 200 --pretty ddcli appsec custom-rules list @@ -77,6 +81,23 @@ ddcli scopes --command cost Time flags accept `now`, relative values like `now-15m`, RFC3339 timestamps, Unix seconds, or Unix milliseconds. Logs and spans pass time strings through to Datadog; metrics and Error Tracking convert them to the epoch formats required by their APIs. +## Dashboard apply + +`dashboards apply` validates a canonical Datadog dashboard JSON file and creates +or updates it. If the definition contains an `id`, that dashboard is updated. +Otherwise, the command matches by exact title: no match creates a dashboard, +one match updates it, and multiple matches fail without writing. + +Use `--dry-run` to validate the JSON locally without credentials or a write: + +```sh +ddcli dashboards apply dashboards/review-edge-overview.json --dry-run --pretty +ddcli dashboards apply dashboards/review-edge-overview.json --pretty +``` + +The application key or access token needs `dashboards_write`; title-based +matching also needs `dashboards_read`. + ## Required Permissions Use `ddcli scopes` to print the Datadog permissions and OAuth scopes needed by each command group. This command does not require Datadog credentials. @@ -124,4 +145,6 @@ Each archive contains a single `ddcli` binary. Run `ddcli --version` to see the bin/build ``` -`bin/build` runs unit tests, builds `dist/ddcli`, and smoke-tests the help output for the main read-only command groups. Live Datadog smoke tests require credentials and are intentionally manual for now. +`bin/build` runs unit tests, builds `dist/ddcli`, and smoke-tests the help output +for the main command groups. Live Datadog smoke tests require credentials and +are intentionally manual for now. diff --git a/cmd/dashboards.go b/cmd/dashboards.go index 9bef65a..46a7f6e 100644 --- a/cmd/dashboards.go +++ b/cmd/dashboards.go @@ -1,6 +1,9 @@ package cmd import ( + "encoding/json" + "fmt" + "os" "strings" "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" @@ -11,7 +14,7 @@ import ( var dashboardsCmd = &cobra.Command{ Use: "dashboards", Aliases: []string{"dashboard"}, - Short: "List and retrieve Datadog dashboards", + Short: "List, retrieve, and apply Datadog dashboards", } var dashboardsListCmd = &cobra.Command{ @@ -27,15 +30,28 @@ var dashboardsGetCmd = &cobra.Command{ RunE: runDashboardsGet, } +var dashboardsApplyCmd = &cobra.Command{ + Use: "apply ", + Short: "Create or update a dashboard from JSON", + Long: `Create or update a dashboard from a canonical JSON definition. + +When the JSON contains an id, apply updates that dashboard. Otherwise apply +matches an existing dashboard by exact title. It refuses to choose when more +than one dashboard has the same title, preventing accidental overwrites.`, + Args: cobra.ExactArgs(1), + RunE: runDashboardsApply, +} + func init() { rootCmd.AddCommand(dashboardsCmd) - dashboardsCmd.AddCommand(dashboardsListCmd, dashboardsGetCmd) + dashboardsCmd.AddCommand(dashboardsListCmd, dashboardsGetCmd, dashboardsApplyCmd) dashboardsListCmd.Flags().String("query", "", "Client-side filter for dashboard title, id, or URL") dashboardsListCmd.Flags().Int64("limit", 100, "Maximum dashboards to request") dashboardsListCmd.Flags().Int64("start", 0, "Offset for dashboard listing") dashboardsListCmd.Flags().Bool("deleted", false, "Include deleted dashboards") dashboardsListCmd.Flags().Bool("shared", false, "Only include shared dashboards") + dashboardsApplyCmd.Flags().Bool("dry-run", false, "Validate JSON and show the intended match without writing") } func runDashboardsList(cmd *cobra.Command, args []string) error { @@ -102,6 +118,117 @@ func runDashboardsGet(cmd *cobra.Command, args []string) error { ) } +func runDashboardsApply(cmd *cobra.Command, args []string) error { + dashboard, err := loadDashboard(args[0]) + if err != nil { + return err + } + dryRun, _ := cmd.Flags().GetBool("dry-run") + if dryRun { + return output.WriteEnvelope(cmd.OutOrStdout(), + map[string]any{"command": "dashboards apply", "file": args[0]}, + map[string]any{"dry_run": true}, + map[string]any{ + "action": intendedDashboardAction(dashboard), + "dashboard_id": dashboard.GetId(), + "title": dashboard.Title, + "widgets": len(dashboard.Widgets), + }, + outputOptions(), + ) + } + + client, err := datadogClient(cmd) + if err != nil { + return err + } + api := datadogV1.NewDashboardsApi(client.API) + id := dashboard.GetId() + action := "update" + if id == "" { + resp, httpResp, err := api.ListDashboards(client.Context, + *datadogV1.NewListDashboardsOptionalParameters().WithCount(1000)) + if err != nil { + return apiError("dashboards apply lookup", httpResp, err) + } + id, err = exactDashboardID(resp.Dashboards, dashboard.Title) + if err != nil { + return err + } + } + + var result datadogV1.Dashboard + if id == "" { + action = "create" + created, response, createErr := api.CreateDashboard(client.Context, dashboard) + if createErr != nil { + return apiError("dashboards apply create", response, createErr) + } + result = created + return output.WriteEnvelope(cmd.OutOrStdout(), + map[string]any{"command": "dashboards apply", "file": args[0]}, + meta(client.Site, map[string]any{"action": action}, response), + result, + outputOptions(), + ) + } + + dashboard.Id = nil + updated, response, updateErr := api.UpdateDashboard(client.Context, id, dashboard) + if updateErr != nil { + return apiError("dashboards apply update", response, updateErr) + } + result = updated + return output.WriteEnvelope(cmd.OutOrStdout(), + map[string]any{"command": "dashboards apply", "file": args[0]}, + meta(client.Site, map[string]any{"action": action}, response), + result, + outputOptions(), + ) +} + +func loadDashboard(path string) (datadogV1.Dashboard, error) { + var dashboard datadogV1.Dashboard + body, err := os.ReadFile(path) + if err != nil { + return dashboard, fmt.Errorf("read dashboard JSON: %w", err) + } + if err := json.Unmarshal(body, &dashboard); err != nil { + return dashboard, fmt.Errorf("parse dashboard JSON: %w", err) + } + if strings.TrimSpace(dashboard.Title) == "" { + return dashboard, fmt.Errorf("dashboard title is required") + } + if len(dashboard.Widgets) == 0 { + return dashboard, fmt.Errorf("dashboard widgets are required") + } + return dashboard, nil +} + +func intendedDashboardAction(dashboard datadogV1.Dashboard) string { + if dashboard.GetId() != "" { + return "update-by-id" + } + return "create-or-update-by-title" +} + +func exactDashboardID(dashboards []datadogV1.DashboardSummaryDefinition, title string) (string, error) { + var ids []string + for _, dashboard := range dashboards { + if dashboard.GetTitle() == title { + ids = append(ids, dashboard.GetId()) + } + } + switch len(ids) { + case 0: + return "", nil + case 1: + return ids[0], nil + default: + return "", fmt.Errorf("refusing to apply: %d dashboards have exact title %q", len(ids), title) + } +} + func filterDashboards(dashboards []datadogV1.DashboardSummaryDefinition, query string) []datadogV1.DashboardSummaryDefinition { query = strings.ToLower(query) filtered := make([]datadogV1.DashboardSummaryDefinition, 0, len(dashboards)) diff --git a/cmd/dashboards_test.go b/cmd/dashboards_test.go new file mode 100644 index 0000000..891da55 --- /dev/null +++ b/cmd/dashboards_test.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" +) + +func TestLoadDashboard(t *testing.T) { + path := filepath.Join(t.TempDir(), "dashboard.json") + err := os.WriteFile(path, []byte(`{ + "title": "Review Edge Overview", + "layout_type": "ordered", + "widgets": [{"definition": {"type": "note", "content": "test"}}] + }`), 0o600) + if err != nil { + t.Fatal(err) + } + + dashboard, err := loadDashboard(path) + if err != nil { + t.Fatalf("load dashboard: %v", err) + } + if dashboard.Title != "Review Edge Overview" { + t.Fatalf("unexpected title: %q", dashboard.Title) + } + if len(dashboard.Widgets) != 1 { + t.Fatalf("expected one widget, got %d", len(dashboard.Widgets)) + } +} + +func TestLoadDashboardRejectsInvalidDefinition(t *testing.T) { + path := filepath.Join(t.TempDir(), "dashboard.json") + if err := os.WriteFile(path, []byte(`{ + "title":"No widgets", + "layout_type":"ordered", + "widgets":[] + }`), 0o600); err != nil { + t.Fatal(err) + } + + _, err := loadDashboard(path) + if err == nil || !strings.Contains(err.Error(), "widgets are required") { + t.Fatalf("expected widgets validation error, got %v", err) + } +} + +func TestExactDashboardID(t *testing.T) { + dashboards := []datadogV1.DashboardSummaryDefinition{ + summary("abc", "Review Edge Overview"), + summary("def", "Other dashboard"), + } + + id, err := exactDashboardID(dashboards, "Review Edge Overview") + if err != nil { + t.Fatal(err) + } + if id != "abc" { + t.Fatalf("expected abc, got %q", id) + } +} + +func TestExactDashboardIDRefusesDuplicateTitles(t *testing.T) { + dashboards := []datadogV1.DashboardSummaryDefinition{ + summary("abc", "Review Edge Overview"), + summary("def", "Review Edge Overview"), + } + + _, err := exactDashboardID(dashboards, "Review Edge Overview") + if err == nil || !strings.Contains(err.Error(), "2 dashboards") { + t.Fatalf("expected duplicate-title error, got %v", err) + } +} + +func summary(id, title string) datadogV1.DashboardSummaryDefinition { + dashboard := datadogV1.NewDashboardSummaryDefinition() + dashboard.SetId(id) + dashboard.SetTitle(title) + return *dashboard +} diff --git a/cmd/helpers.go b/cmd/helpers.go index ea661ec..9f212dd 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -1,8 +1,11 @@ package cmd import ( + "encoding/json" + "errors" "fmt" "net/http" + "strings" "github.com/spf13/cobra" ) @@ -40,8 +43,30 @@ func apiError(operation string, resp *http.Response, err error) error { if err == nil { return nil } + detail := datadogErrorDetail(err) if resp == nil { - return fmt.Errorf("%s failed: %w", operation, err) + return fmt.Errorf("%s failed: %w%s", operation, err, detail) } - return fmt.Errorf("%s failed: %w (http status %d)", operation, err, resp.StatusCode) + return fmt.Errorf("%s failed: %w (http status %d)%s", operation, err, resp.StatusCode, detail) +} + +func datadogErrorDetail(err error) string { + var bodyError interface{ Body() []byte } + if !errors.As(err, &bodyError) { + return "" + } + var response struct { + Errors []string `json:"errors"` + } + if json.Unmarshal(bodyError.Body(), &response) != nil || len(response.Errors) == 0 { + return "" + } + for i, message := range response.Errors { + response.Errors[i] = strings.TrimSpace(message) + } + detail := strings.Join(response.Errors, "; ") + if len(detail) > 500 { + detail = detail[:500] + "..." + } + return ": " + detail } diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go new file mode 100644 index 0000000..c424fe2 --- /dev/null +++ b/cmd/helpers_test.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "errors" + "strings" + "testing" +) + +type testBodyError struct { + body []byte +} + +func (e testBodyError) Error() string { + return "400 Bad Request" +} + +func (e testBodyError) Body() []byte { + return e.body +} + +func TestDatadogErrorDetailExtractsErrors(t *testing.T) { + err := testBodyError{body: []byte(`{"errors":["invalid widget type","title is required"]}`)} + + got := datadogErrorDetail(err) + + if got != ": invalid widget type; title is required" { + t.Fatalf("unexpected error detail: %q", got) + } +} + +func TestDatadogErrorDetailIgnoresUnknownBodies(t *testing.T) { + if got := datadogErrorDetail(errors.New("failure")); got != "" { + t.Fatalf("expected no detail, got %q", got) + } +} + +func TestDatadogErrorDetailLimitsOutput(t *testing.T) { + err := testBodyError{body: []byte(`{"errors":["` + strings.Repeat("x", 600) + `"]}`)} + + got := datadogErrorDetail(err) + + if len(got) != 505 || !strings.HasSuffix(got, "...") { + t.Fatalf("expected bounded detail, got length %d", len(got)) + } +} diff --git a/cmd/root.go b/cmd/root.go index 5eb57e3..40cb0ac 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,8 +27,9 @@ var globals globalOptions var rootCmd = &cobra.Command{ Use: "ddcli", Version: Version, - Short: "Read-only Datadog CLI for scripts and LLM agents", - Long: `ddcli wraps read-only Datadog APIs with stable JSON output. + Short: "Datadog CLI for scripts and LLM agents", + Long: `ddcli wraps Datadog APIs with stable JSON output and narrow, +explicit write commands. Credentials can be supplied with DD_API_KEY and DD_APP_KEY, or with DD_ACCESS_TOKEN for OAuth-style bearer access tokens.`, diff --git a/cmd/scopes.go b/cmd/scopes.go index f13ebb7..de102ca 100644 --- a/cmd/scopes.go +++ b/cmd/scopes.go @@ -134,6 +134,15 @@ func requiredScopes() []scopeInfo { Permission: "dashboards_read", OAuthScope: "dashboards_read", }, + { + Command: "dashboards apply", + API: "Dashboards API", + Permission: "dashboards_write", + OAuthScope: "dashboards_write", + Notes: []string{ + "Apply also needs dashboards_read to match an existing dashboard by title.", + }, + }, { Command: "apm spans", API: "Spans API",