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: 29 additions & 0 deletions .chloggen/worktree-export.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Use this changelog template to create an entry for release notes.

# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
change_type: enhancement

# The name of the component, or a single word describing the area of concern (e.g. dashboards, config, apply)
component: dashboards

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: "`<asset> list -o yaml` and `-o json` now output full asset definitions instead of summary list items"

# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
issues: [67]

# (Optional) One or more lines of additional information to render under the primary note.
# These lines will be padded with 2 spaces and then inserted directly into the document.
# Use pipe (|) for multiline entries.
subtext: |
YAML output is a multi-document stream (separated by `---`) that can be piped directly to `dash0 apply -f -`.
JSON output is an array of full asset definitions.
This applies to all four asset types: dashboards, check-rules, views, and synthetic-checks.

# If your change doesn't affect end users or the exported elements of any package,
# you should instead start your pull request title with "chore" or use the "Skip Changelog" label.
# Optional: The change log or logs in which this entry should be included.
# e.g. '[user]' or '[user, api]'
# Include 'user' if the change is relevant to end users.
# Default: '[user]'
change_logs: []
9 changes: 8 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ NAME ID DATASET ORIGIN
Production Overview a1b2c3d4-5678-90ab-cdef-1234567890ab default gitops/prod https://app.dash0.com/goto/dashboards?dashboard_id=a1b2c3d4-...
```

Use `-o json` or `-o yaml` to get the full asset payload, suitable for piping or saving to a file.
Use `-o json` or `-o yaml` to get the full asset definitions, suitable for backup or re-applying with `apply -f -`.
The YAML output is a multi-document stream (documents separated by `---`) so it can be piped directly to `dash0 apply -f -`.
Use `-o csv` for a pipe-friendly, machine-readable format with the same columns as `wide`:

```bash
Expand Down Expand Up @@ -1174,8 +1175,14 @@ dash0 apply -f dashboard.yaml

### Bulk export all assets of one type

The YAML output contains full asset definitions as a multi-document stream, ready to be re-applied:

```bash
# Export all dashboards
dash0 dashboards list -o yaml > all-dashboards.yaml

# Re-apply them later
dash0 apply -f all-dashboards.yaml
```

### Send a deployment event
Expand Down
7 changes: 4 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ require (
github.com/dash0hq/dash0-api-client-go v1.5.1
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/mattn/go-isatty v0.0.20
github.com/pmezard/go-difflib v1.0.0
github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/collector/pdata v1.51.0
golang.org/x/term v0.40.0
gopkg.in/yaml.v3 v3.0.1
sigs.k8s.io/yaml v1.6.0
)
Expand All @@ -22,14 +23,14 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/oapi-codegen/runtime v1.1.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
go.opentelemetry.io/collector/featuregate v1.51.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/sys v0.41.0 // indirect
)
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
7 changes: 1 addition & 6 deletions internal/checkrules/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,7 @@ func runGet(ctx context.Context, id string, flags *asset.GetFlags) error {
})
}

// The API does not return the rule ID in the response body. Restore it
// so that exported YAML can be re-applied (the import API uses the ID
// for upsert).
if rule.Id == nil {
rule.Id = &id
}
enrichCheckRule(rule, id)

format, err := output.ParseFormat(flags.Output)
if err != nil {
Expand Down
82 changes: 82 additions & 0 deletions internal/checkrules/integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//go:build integration

package checkrules

import (
"net/http"
"regexp"
"testing"

"github.com/dash0hq/dash0-cli/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const (
apiPathCheckRules = "/api/alerting/check-rules"
fixtureListSuccess = "checkrules/list_success.json"
fixtureListEmpty = "checkrules/list_empty.json"
fixtureGetSuccess = "checkrules/get_success.json"
fixtureUnauthorized = "dashboards/error_unauthorized.json"
)

var checkRuleIDPattern = regexp.MustCompile(`^/api/alerting/check-rules/[a-f0-9-]+$`)

func TestListCheckRules_JSONFormat(t *testing.T) {
testutil.SetupTestEnv(t)

server := testutil.NewMockServer(t, testutil.FixturesDir())
server.On(http.MethodGet, apiPathCheckRules, testutil.MockResponse{
StatusCode: http.StatusOK,
BodyFile: fixtureListSuccess,
Validator: testutil.RequireHeaders,
})
server.OnPattern(http.MethodGet, checkRuleIDPattern, testutil.MockResponse{
StatusCode: http.StatusOK,
BodyFile: fixtureGetSuccess,
Validator: testutil.RequireHeaders,
})

cmd := NewCheckRulesCmd()
cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "json", "--limit", "2"})

var err error
output := testutil.CaptureStdout(t, func() {
err = cmd.Execute()
})

require.NoError(t, err)
// JSON output should contain full check rule definitions
assert.Contains(t, output, `"name"`)
assert.Contains(t, output, `"expression"`)
assert.Contains(t, output, `"Failing check rule 2"`)
}

func TestListCheckRules_YAMLFormat(t *testing.T) {
testutil.SetupTestEnv(t)

server := testutil.NewMockServer(t, testutil.FixturesDir())
server.On(http.MethodGet, apiPathCheckRules, testutil.MockResponse{
StatusCode: http.StatusOK,
BodyFile: fixtureListSuccess,
Validator: testutil.RequireHeaders,
})
server.OnPattern(http.MethodGet, checkRuleIDPattern, testutil.MockResponse{
StatusCode: http.StatusOK,
BodyFile: fixtureGetSuccess,
Validator: testutil.RequireHeaders,
})

cmd := NewCheckRulesCmd()
cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "yaml", "--limit", "2"})

var err error
output := testutil.CaptureStdout(t, func() {
err = cmd.Execute()
})

require.NoError(t, err)
// YAML output should contain full check rule definitions
assert.Contains(t, output, "name: Failing check rule 2")
assert.Contains(t, output, "expression:")
}
41 changes: 39 additions & 2 deletions internal/checkrules/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ func runList(ctx context.Context, flags *asset.ListFlags) error {
return err
}

iter := apiClient.ListCheckRulesIter(ctx, client.ResolveDataset(ctx, flags.Dataset))
dataset := client.ResolveDataset(ctx, flags.Dataset)
iter := apiClient.ListCheckRulesIter(ctx, dataset)

var rules []*dash0api.PrometheusAlertRuleApiListItem
count := 0
Expand All @@ -82,12 +83,48 @@ func runList(ctx context.Context, flags *asset.ListFlags) error {

switch format {
case output.FormatJSON, output.FormatYAML:
return formatter.Print(rules)
definitions, err := fetchFullCheckRules(ctx, apiClient, rules, dataset)
if err != nil {
return err
}
if format == output.FormatYAML {
return formatter.PrintMultiDocYAML(definitions)
}
return formatter.PrintJSON(definitions)
default:
return printCheckRuleTable(formatter, rules, format, apiUrl)
}
}

func fetchFullCheckRules(
ctx context.Context,
apiClient dash0api.Client,
rules []*dash0api.PrometheusAlertRuleApiListItem,
dataset *string,
) ([]interface{}, error) {
progress := output.NewProgress("check rules", len(rules))
defer progress.Done()
definitions := make([]interface{}, 0, len(rules))
for i, item := range rules {
progress.Update(i + 1)
rule, err := apiClient.GetCheckRule(ctx, item.Id, dataset)
if err != nil {
return nil, fmt.Errorf("failed to fetch check rule %q: %w", item.Id, err)
}
enrichCheckRule(rule, item.Id)
definitions = append(definitions, rule)
}
return definitions, nil
}

// enrichCheckRule restores the rule ID so that exported YAML can be re-applied
// (the API does not return the rule ID in the response body).
func enrichCheckRule(rule *dash0api.PrometheusAlertRule, id string) {
if rule.Id == nil {
rule.Id = &id
}
}

func printCheckRuleTable(f *output.Formatter, rules []*dash0api.PrometheusAlertRuleApiListItem, format output.Format, apiUrl string) error {
columns := []output.Column{
{Header: internal.HEADER_NAME, Width: 40, Value: func(item interface{}) string {
Expand Down
4 changes: 2 additions & 2 deletions internal/color/severity.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (

"github.com/dash0hq/dash0-cli/internal/otlp"
"github.com/fatih/color"
"github.com/mattn/go-isatty"
"golang.org/x/term"
)

var (
Expand All @@ -21,7 +21,7 @@ var (
// When color is disabled (via color.NoColor) or stdout is not a TTY, the
// severity is returned as plain left-padded text.
func SprintSeverity(sev string, width int) string {
if color.NoColor || !isatty.IsTerminal(os.Stdout.Fd()) {
if color.NoColor || !term.IsTerminal(int(os.Stdout.Fd())) {
if width > 0 {
return fmt.Sprintf("%-*s", width, sev)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/color/span_status_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import (
"os"

"github.com/fatih/color"
"github.com/mattn/go-isatty"
"golang.org/x/term"
)

// SprintSpanStatus returns the span status string color-coded and padded to
// width visible characters for terminal output. When width is 0, no padding
// is applied.
func SprintSpanStatus(status string, width int) string {
if color.NoColor || !isatty.IsTerminal(os.Stdout.Fd()) {
if color.NoColor || !term.IsTerminal(int(os.Stdout.Fd())) {
if width > 0 {
return fmt.Sprintf("%-*s", width, status)
}
Expand Down
11 changes: 1 addition & 10 deletions internal/dashboards/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"
"os"

dash0api "github.com/dash0hq/dash0-api-client-go"
"github.com/dash0hq/dash0-cli/internal"
"github.com/dash0hq/dash0-cli/internal/asset"
"github.com/dash0hq/dash0-cli/internal/client"
Expand Down Expand Up @@ -53,15 +52,7 @@ func runGet(ctx context.Context, id string, flags *asset.GetFlags) error {
})
}

// The API does not persist dash0Extensions.id for dashboards. Restore the
// ID we used for lookup so that exported YAML can be re-applied (the import
// API uses dash0Extensions.id as the upsert key).
if dashboard.Metadata.Dash0Extensions == nil {
dashboard.Metadata.Dash0Extensions = &dash0api.DashboardMetadataExtensions{}
}
if dashboard.Metadata.Dash0Extensions.Id == nil {
dashboard.Metadata.Dash0Extensions.Id = &id
}
enrichDashboard(dashboard, id)

// Format output
format, err := output.ParseFormat(flags.Output)
Expand Down
49 changes: 45 additions & 4 deletions internal/dashboards/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,19 +201,60 @@ func TestListDashboards_JSONFormat(t *testing.T) {
BodyFile: fixtureListSuccess,
Validator: testutil.RequireHeaders,
})
server.OnPattern(http.MethodGet, dashboardIDPattern, testutil.MockResponse{
StatusCode: http.StatusOK,
BodyFile: fixtureGetSuccess,
Validator: testutil.RequireHeaders,
})

cmd := NewDashboardsCmd()
cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "json"})
// Use --limit 2 to keep the output small enough for the CaptureStdout pipe.
cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "json", "--limit", "2"})

var err error
output := testutil.CaptureStdout(t, func() {
err = cmd.Execute()
})

require.NoError(t, err)
// JSON format should be valid JSON with dashboard data
assert.Contains(t, output, `"id"`)
assert.Contains(t, output, `"0c3893ac-3d26-11ef-943e-eedf0419e619"`)
// JSON output should contain full dashboard definitions
assert.Contains(t, output, `"kind": "Dashboard"`)
assert.Contains(t, output, `"metadata"`)
assert.Contains(t, output, `"spec"`)
}

func TestListDashboards_YAMLFormat(t *testing.T) {
testutil.SetupTestEnv(t)

server := testutil.NewMockServer(t, testutil.FixturesDir())
server.On(http.MethodGet, apiPathDashboards, testutil.MockResponse{
StatusCode: http.StatusOK,
BodyFile: fixtureListSuccess,
Validator: testutil.RequireHeaders,
})
server.OnPattern(http.MethodGet, dashboardIDPattern, testutil.MockResponse{
StatusCode: http.StatusOK,
BodyFile: fixtureGetSuccess,
Validator: testutil.RequireHeaders,
})

cmd := NewDashboardsCmd()
// Use --limit 2 to keep the output small enough for the CaptureStdout pipe
// while still verifying multi-document output.
cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "yaml", "--limit", "2"})

var err error
output := testutil.CaptureStdout(t, func() {
err = cmd.Execute()
})

require.NoError(t, err)
// YAML output should contain full dashboard definitions as multi-document YAML
assert.Contains(t, output, "kind: Dashboard")
assert.Contains(t, output, "metadata:")
assert.Contains(t, output, "spec:")
// Multiple documents should be separated by ---
assert.Contains(t, output, "---")
}

func TestGetDashboard_Success(t *testing.T) {
Expand Down
Loading
Loading