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
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
131 changes: 129 additions & 2 deletions cmd/dashboards.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package cmd

import (
"encoding/json"
"fmt"
"os"
"strings"

"github.com/DataDog/datadog-api-client-go/v2/api/datadogV1"
Expand All @@ -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{
Expand All @@ -27,15 +30,28 @@ var dashboardsGetCmd = &cobra.Command{
RunE: runDashboardsGet,
}

var dashboardsApplyCmd = &cobra.Command{
Use: "apply <dashboard.json>",
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 {
Expand Down Expand Up @@ -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))
Expand Down
83 changes: 83 additions & 0 deletions cmd/dashboards_test.go
Original file line number Diff line number Diff line change
@@ -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
}
29 changes: 27 additions & 2 deletions cmd/helpers.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package cmd

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"

"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -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
}
Loading
Loading