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
26 changes: 26 additions & 0 deletions .chloggen/diff-output.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# 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: output

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: The `update` and `apply` commands now show a unified diff of changes

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

# (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:

# 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: []
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,8 @@ The `list` and `get` commands for assets support multiple output formats via `-o
- **`yaml`**: Full asset data in YAML format
- **`csv`**: Comma-separated values with the same columns as `wide`, suitable for piping and automation

The `update` and `apply` commands show a unified diff of changes.

The `logs query`, `spans query`, and `traces get` commands support a different set of formats via `-o`:

- **`table`** (default): Columnar output (`logs query` shows timestamp, severity, and body; `spans query` shows timestamp, duration, name, status, service, and trace ID; `traces get` shows a hierarchical span tree)
Expand Down
34 changes: 29 additions & 5 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,21 +288,42 @@ Aliases: `add`

Update an existing asset from a YAML or JSON file.
If the ID argument is omitted, the ID is extracted from the file content.
The output shows a unified diff of what changed.
The `--dry-run` flag shows the diff without applying the update.

```bash
dash0 dashboards update [id] -f <file> [--dry-run] [-o <format>]
dash0 dashboards update [id] -f <file> [--dry-run]
```

Examples:

```bash
# Update with explicit ID
# Update a dashboard from a file
$ dash0 dashboards update <id> -f dashboard.yaml
Dashboard "My Dashboard" updated successfully
--- Dashboard (before)
+++ Dashboard (after)
@@ -2,7 +2,7 @@
spec:
display:
- name: Old Dashboard Name
+ name: New Dashboard Name

# Update using the ID from the file
# Preview changes without applying
$ dash0 dashboards update -f dashboard.yaml --dry-run
--- Dashboard (before)
+++ Dashboard (after)
@@ -2,7 +2,7 @@
spec:
display:
- name: Old Dashboard Name
+ name: New Dashboard Name
```

When nothing changed:

```bash
$ dash0 dashboards update -f dashboard.yaml
Dashboard "My Dashboard" updated successfully
Dashboard "My Dashboard": no changes
```

### `delete`
Expand Down Expand Up @@ -350,6 +371,9 @@ dash0 apply -f <file|directory> [--dry-run]
| `--file` | `-f` | Path to a YAML/JSON file, a directory, or `-` for stdin |
| `--dry-run` | | Validate without applying |

For assets that are updated, a unified diff of the changes is shown.
Assets that are created show the standard creation message.

When a directory is specified, all `.yaml` and `.yml` files are discovered recursively.
Hidden files and directories (starting with `.`) are skipped.
All documents are validated before any are applied.
Expand Down
19 changes: 14 additions & 5 deletions internal/apply/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ type applyResult struct {
name string
id string
action applyAction
before any // asset state before update (nil for creates)
after any // asset state after update/create
}

func runApply(ctx context.Context, flags *applyFlags) error {
Expand Down Expand Up @@ -191,7 +193,12 @@ func runApply(ctx context.Context, flags *applyFlags) error {
displayKind := asset.KindDisplayName(r.kind)
label := formatNameAndId(r.name, r.id)
applied = append(applied, fmt.Sprintf("%s %s", displayKind, label))
if fromDirectory {

if r.action == actionUpdated && r.before != nil {
if err := asset.PrintDiff(os.Stdout, displayKind, r.name, r.before, r.after); err != nil {
return err
}
} else if fromDirectory {
fmt.Printf("%s: %s %s %s\n", doc.filePath, displayKind, label, r.action)
} else {
fmt.Printf("%s %s %s\n", displayKind, label, r.action)
Expand Down Expand Up @@ -550,7 +557,7 @@ func applyDocument(ctx context.Context, apiClient dash0api.Client, doc assetDocu
AssetName: asset.ExtractDashboardDisplayName(&dashboard),
})
}
return []applyResult{{kind: doc.kind, name: result.Name, id: result.ID, action: applyAction(result.Action)}}, nil
return []applyResult{{kind: doc.kind, name: result.Name, id: result.ID, action: applyAction(result.Action), before: result.Before, after: result.After}}, nil

case "checkrule":
var rule dash0api.PrometheusAlertRule
Expand All @@ -564,7 +571,7 @@ func applyDocument(ctx context.Context, apiClient dash0api.Client, doc assetDocu
AssetName: rule.Name,
})
}
return []applyResult{{kind: doc.kind, name: result.Name, id: result.ID, action: applyAction(result.Action)}}, nil
return []applyResult{{kind: doc.kind, name: result.Name, id: result.ID, action: applyAction(result.Action), before: result.Before, after: result.After}}, nil

case "prometheusrule":
var promRule asset.PrometheusRule
Expand Down Expand Up @@ -599,7 +606,7 @@ func applyDocument(ctx context.Context, apiClient dash0api.Client, doc assetDocu
AssetName: check.Metadata.Name,
})
}
return []applyResult{{kind: doc.kind, name: result.Name, id: result.ID, action: applyAction(result.Action)}}, nil
return []applyResult{{kind: doc.kind, name: result.Name, id: result.ID, action: applyAction(result.Action), before: result.Before, after: result.After}}, nil

case "view":
var view dash0api.ViewDefinition
Expand All @@ -613,7 +620,7 @@ func applyDocument(ctx context.Context, apiClient dash0api.Client, doc assetDocu
AssetName: view.Metadata.Name,
})
}
return []applyResult{{kind: doc.kind, name: result.Name, id: result.ID, action: applyAction(result.Action)}}, nil
return []applyResult{{kind: doc.kind, name: result.Name, id: result.ID, action: applyAction(result.Action), before: result.Before, after: result.After}}, nil

default:
return nil, fmt.Errorf("unsupported kind: %s", doc.kind)
Expand All @@ -633,6 +640,8 @@ func applyPrometheusRule(ctx context.Context, apiClient dash0api.Client, promRul
name: r.Name,
id: r.ID,
action: applyAction(r.Action),
before: r.Before,
after: r.After,
})
}

Expand Down
12 changes: 8 additions & 4 deletions internal/apply/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,9 @@ expression: up == 0
})

require.NoError(t, cmdErr)
// Output is a diff; since GET and Import return the same fixture, expect "no changes"
assert.Contains(t, output, "Check rule")
assert.Contains(t, output, "updated")
assert.Contains(t, output, "no changes")

// Verify the import request body
importReq := findImportRequest(server.Requests(), apiPathImportCheckRule)
Expand Down Expand Up @@ -189,8 +190,9 @@ spec:
})

require.NoError(t, cmdErr)
// Output is a diff; since GET and Import return the same fixture, expect "no changes"
assert.Contains(t, output, "Dashboard")
assert.Contains(t, output, "updated")
assert.Contains(t, output, "no changes")

// Verify the import request body has server-generated fields stripped
importReq := findImportRequest(server.Requests(), apiPathImportDashboard)
Expand Down Expand Up @@ -930,8 +932,9 @@ spec:
})

require.NoError(t, cmdErr)
// Output is a diff; since GET and Import return the same fixture, expect "no changes"
assert.Contains(t, output, "View")
assert.Contains(t, output, "updated")
assert.Contains(t, output, "no changes")

// Verify the import request body has server-generated fields stripped
importReq := findImportRequest(server.Requests(), apiPathImportView)
Expand Down Expand Up @@ -989,8 +992,9 @@ spec:
})

require.NoError(t, cmdErr)
// Output is a diff; since GET and Import return the same fixture, expect "no changes"
assert.Contains(t, output, "Synthetic check")
assert.Contains(t, output, "updated")
assert.Contains(t, output, "no changes")

// Verify the import request body has server-generated fields stripped
importReq := findImportRequest(server.Requests(), apiPathImportSyntheticCheck)
Expand Down
23 changes: 15 additions & 8 deletions internal/asset/checkrule.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,29 @@ import (
dash0api "github.com/dash0hq/dash0-api-client-go"
)

// StripCheckRuleServerFields removes server-generated fields from a check rule
// definition. Used by both Import (to avoid sending rejected fields to the API)
// and diff rendering (to suppress noise).
func StripCheckRuleServerFields(r *dash0api.PrometheusAlertRule) {
r.Dataset = nil
if r.Labels != nil {
delete(*r.Labels, "dash0.com/origin")
}
}

// ImportCheckRule checks existence by rule ID, strips the ID when the asset
// is not found, and calls the import API.
func ImportCheckRule(ctx context.Context, apiClient dash0api.Client, rule *dash0api.PrometheusAlertRule, dataset *string) (ImportResult, error) {
// Strip server-managed fields — the import API manages these and rejects
// requests that include them (e.g. dataset).
rule.Dataset = nil
if rule.Labels != nil {
delete(*rule.Labels, "dash0.com/origin")
}
StripCheckRuleServerFields(rule)

// Check if check rule exists
action := ActionCreated
var before any
if rule.Id != nil && *rule.Id != "" {
_, err := apiClient.GetCheckRule(ctx, *rule.Id, dataset)
existing, err := apiClient.GetCheckRule(ctx, *rule.Id, dataset)
if err == nil {
action = ActionUpdated
before = existing
} else {
// Asset not found — strip ID so the API creates a fresh asset.
rule.Id = nil
Expand All @@ -37,7 +44,7 @@ func ImportCheckRule(ctx context.Context, apiClient dash0api.Client, rule *dash0
if result.Id != nil {
id = *result.Id
}
return ImportResult{Name: result.Name, ID: id, Action: action}, nil
return ImportResult{Name: result.Name, ID: id, Action: action, Before: before, After: result}, nil
}

// ExtractCheckRuleID extracts the ID from a check rule definition.
Expand Down
33 changes: 18 additions & 15 deletions internal/asset/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,38 @@ import (
"github.com/google/uuid"
)

// StripDashboardServerFields removes server-generated metadata fields from a
// dashboard definition. Used by both Import (to avoid sending stale values to
// the API) and diff rendering (to suppress noise like timestamps and version).
func StripDashboardServerFields(d *dash0api.DashboardDefinition) {
d.Metadata.Annotations = nil
d.Metadata.CreatedAt = nil
d.Metadata.UpdatedAt = nil
d.Metadata.Version = nil
if d.Metadata.Dash0Extensions != nil {
d.Metadata.Dash0Extensions.Dataset = nil
}
}

// ImportDashboard checks existence by dash0Extensions.id,
// strips server-generated fields when the asset is not found, and calls the
// import API. The import API uses dash0Extensions.id as the upsert key; when
// the input has no id, a fresh UUID is generated so re-applying the exported
// YAML updates the dashboard instead of creating a duplicate.
func ImportDashboard(ctx context.Context, apiClient dash0api.Client, dashboard *dash0api.DashboardDefinition, dataset *string) (ImportResult, error) {
// Use dash0Extensions.id for the existence check — this is the upsert key
// used by the import API. Note: metadata.name for dashboards is the display
// name, not a UUID, so it cannot be used for lookups.

// Strip server-generated metadata fields — the import API manages these
// and rejects requests that include stale values (e.g. an outdated version).
dashboard.Metadata.Annotations = nil
dashboard.Metadata.CreatedAt = nil
dashboard.Metadata.UpdatedAt = nil
dashboard.Metadata.Version = nil
if dashboard.Metadata.Dash0Extensions != nil {
dashboard.Metadata.Dash0Extensions.Dataset = nil
}
StripDashboardServerFields(dashboard)

action := ActionCreated
var before any
id := ""
if dashboard.Metadata.Dash0Extensions != nil && dashboard.Metadata.Dash0Extensions.Id != nil {
id = *dashboard.Metadata.Dash0Extensions.Id
}
if id != "" {
_, err := apiClient.GetDashboard(ctx, id, dataset)
existing, err := apiClient.GetDashboard(ctx, id, dataset)
if err == nil {
action = ActionUpdated
before = existing
} else {
// Asset not found — strip the ID so the API creates a fresh asset
// instead of colliding with a soft-deleted record.
Expand Down Expand Up @@ -64,7 +67,7 @@ func ImportDashboard(ctx context.Context, apiClient dash0api.Client, dashboard *
if name == "" {
name = result.Metadata.Name
}
return ImportResult{Name: name, ID: id, Action: action}, nil
return ImportResult{Name: name, ID: id, Action: action, Before: before, After: result}, nil
}

// ExtractDashboardID extracts the ID from a dashboard definition.
Expand Down
Loading
Loading