diff --git a/.chloggen/eng-7860-add-to-cli.yaml b/.chloggen/eng-7860-add-to-cli.yaml new file mode 100644 index 0000000..0b7dba8 --- /dev/null +++ b/.chloggen/eng-7860-add-to-cli.yaml @@ -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: new_component + +# The name of the component, or a single word describing the area of concern (e.g. dashboards, config, apply) +component: recording-rules + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Add `recording-rules` command to manage Dash0 recording rules + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [7860] + +# (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: [] diff --git a/README.md b/README.md index 5c2139a..f574a25 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ See the [agent mode specification](docs/commands.md#agent-mode) for the full pri Apply asset definitions from a file, directory, or stdin. The input may contain multiple YAML documents separated by `---`. -Supported asset types: `Dashboard`, `PersesDashboard`, `CheckRule`, `SyntheticCheck`, and `View`. +Supported asset types: `Dashboard`, `PersesDashboard`, `CheckRule`, `SyntheticCheck`, `View`, and `Dash0RecordingRuleGroup`. ```bash dash0 apply -f assets.yaml @@ -252,6 +252,19 @@ dash0 views update [id] -f view.yaml dash0 views delete [--force] ``` +### Recording rules + +```bash +dash0 recording-rules list +dash0 recording-rules get +dash0 recording-rules get -o yaml +dash0 recording-rules create -f recording-rule.yaml +dash0 recording-rules update [id] -f recording-rule.yaml +dash0 recording-rules delete [--force] +``` + +Alias: `rr` (e.g. `dash0 rr list`) + ### Logging #### Sending logs to Dash0 diff --git a/cmd/dash0/main.go b/cmd/dash0/main.go index 0b939a2..68a88e8 100644 --- a/cmd/dash0/main.go +++ b/cmd/dash0/main.go @@ -17,6 +17,7 @@ import ( "github.com/dash0hq/dash0-cli/internal/logging" "github.com/dash0hq/dash0-cli/internal/members" "github.com/dash0hq/dash0-cli/internal/metrics" + "github.com/dash0hq/dash0-cli/internal/recordingrulegroups" "github.com/dash0hq/dash0-cli/internal/syntheticchecks" "github.com/dash0hq/dash0-cli/internal/teams" "github.com/dash0hq/dash0-cli/internal/tracing" @@ -66,6 +67,7 @@ func init() { rootCmd.AddCommand(logging.NewLogsCmd()) rootCmd.AddCommand(members.NewMembersCmd()) rootCmd.AddCommand(metrics.NewMetricsCmd()) + rootCmd.AddCommand(recordingrulegroups.NewRecordingRuleGroupsCmd()) rootCmd.AddCommand(syntheticchecks.NewSyntheticChecksCmd()) rootCmd.AddCommand(teams.NewTeamsCmd()) rootCmd.AddCommand(tracing.NewSpansCmd()) diff --git a/docs/cli-naming-conventions.md b/docs/cli-naming-conventions.md index 6c15e31..817281f 100644 --- a/docs/cli-naming-conventions.md +++ b/docs/cli-naming-conventions.md @@ -5,12 +5,12 @@ The reason for this is that the word "resource" is overloaded in OpenTelemetry, Use the word "asset" consistently where appropriate. ## Top-level Asset Commands -- Use **plural form**: `dashboards`, `views`, `check-rules`, `synthetic-checks` -- Use **kebab-case** for multi-word names: `check-rules`, `synthetic-checks` +- Use **plural form**: `dashboards`, `views`, `check-rules`, `synthetic-checks`, `recording-rules` +- Use **kebab-case** for multi-word names: `check-rules`, `synthetic-checks`, `recording-rules` - Group related functionality: `config profiles` for profile management ## Standard CRUD Subcommands for Assets -All asset commands (`dashboards`, `check-rules`, `views`, `synthetic-checks`) use these subcommands: +All asset commands (`dashboards`, `check-rules`, `views`, `synthetic-checks`, `recording-rules`) use these subcommands: | Subcommand | Alias | Description | |------------|----------|--------------------------------------| @@ -64,14 +64,15 @@ The `members` command manages organization membership: ## Asset Kind Display Names In user-facing output (success messages, dry-run listings, error messages), use human-readable names for asset kinds — **not** PascalCase identifiers: -| Kind identifier | Display name | -|--------------------|-----------------| -| `Dashboard` | Dashboard | -| `CheckRule` | Check rule | -| `SyntheticCheck` | Synthetic check | -| `View` | View | -| `PrometheusRule` | PrometheusRule | -| `PersesDashboard` | PersesDashboard | +| Kind identifier | Display name | +|------------------------------|----------------------| +| `Dashboard` | Dashboard | +| `CheckRule` | Check rule | +| `SyntheticCheck` | Synthetic check | +| `View` | View | +| `PrometheusRule` | PrometheusRule | +| `PersesDashboard` | PersesDashboard | +| `Dash0RecordingRuleGroup` | Recording rule | For example: `Check rule "High Error Rate" created`, not `CheckRule "High Error Rate" created`. diff --git a/docs/commands.md b/docs/commands.md index b2ac5f0..095a216 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -194,7 +194,7 @@ Auth Token: ...ULSzVkM Dash0 calls dashboards, views, synthetic checks, and check rules "assets" (not "resources", which is an overloaded term in OpenTelemetry). -All four asset types (`dashboards`, `check-rules`, `synthetic-checks`, `views`) share the same CRUD subcommands. +All five asset types (`dashboards`, `check-rules`, `recording-rules`, `synthetic-checks`, `views`) share the same CRUD subcommands. The examples below use `dashboards`, but the same patterns apply to every asset type. ### `list` @@ -381,6 +381,7 @@ Aliases: `remove` |------------|---------|-------| | Dashboards | `dash0 dashboards ` | `create` also accepts PersesDashboard CRD files | | Check rules | `dash0 check-rules ` | `create` also accepts PrometheusRule CRD files | +| Recording rules | `dash0 recording-rules ` | Alias: `rr` | | Synthetic checks | `dash0 synthetic-checks ` | | | Views | `dash0 views ` | | @@ -406,7 +407,7 @@ Hidden files and directories (starting with `.`) are skipped. All documents are validated before any are applied. If any document fails validation, no changes are made. -Supported `kind` values: `Dashboard`, `PersesDashboard`, `CheckRule`, `PrometheusRule`, `SyntheticCheck`, `View`. +Supported `kind` values: `Dashboard`, `PersesDashboard`, `CheckRule`, `PrometheusRule`, `SyntheticCheck`, `View`, `Dash0RecordingRuleGroup`. A single file may contain multiple documents separated by `---`. > [!NOTE] @@ -499,6 +500,33 @@ spec: interval: 60s ``` +Recording rule: + +```yaml +kind: Dash0RecordingRuleGroup +metadata: + name: http_metrics + annotations: + dash0.com/folder-path: /infrastructure/http + dash0.com/sharing: "team:team_01abc,user:alice@example.com" +spec: + display: + name: HTTP Metrics + enabled: true + interval: 1m + rules: + - record: http_requests_total:rate5m + expression: rate(http_requests_total[5m]) + labels: + env: production + - record: http_errors_total:rate5m + expression: rate(http_requests_total{status=~"5.."}[5m]) + labels: + env: production + - record: http_request_duration_seconds:p99 + expression: histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, service_name)) +``` + Multi-document file (separated by `---`): ```yaml diff --git a/docs/project-structure.md b/docs/project-structure.md index c6140c1..41ea250 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -5,7 +5,7 @@ - `/internal/agentmode`: Agent mode detection and structured error output for AI coding agents - `/internal/apply`: The `apply` command — orchestration only, delegates asset-specific logic to `internal/asset` - `/internal/asset`: Shared asset logic (types, import functions, display helpers) used by both `apply` and the per-asset CRUD commands -- `/internal/checkrules`, `/internal/dashboards`, `/internal/syntheticchecks`, `/internal/views`: Per-asset CRUD commands — delegate asset-specific logic to `internal/asset` +- `/internal/checkrules`, `/internal/dashboards`, `/internal/recordingrulegroups`, `/internal/syntheticchecks`, `/internal/views`: Per-asset CRUD commands — delegate asset-specific logic to `internal/asset` - `/internal/client`: API client factory and error handling - `/internal/color`: Severity-aware color formatting for terminal output - `/internal/confirmation`: Confirmation prompt for destructive operations (respects `--force` and agent mode) diff --git a/docs/testing.md b/docs/testing.md index 26fba43..89ddd9b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -5,7 +5,7 @@ The OpenAPI specification of the Dash0 API is available at `https://api-docs.das ## Fixture Location - Fixtures are stored in `internal/testutil/fixtures/` -- Organized by asset type: `dashboards/`, `checkrules/`, `views/`, `syntheticchecks/` +- Organized by asset type: `dashboards/`, `checkrules/`, `views/`, `syntheticchecks/`, `recordingrulegroups/` - Common fixture patterns: `list_success.json`, `list_empty.json`, `get_success.json`, `error_not_found.json`, `error_unauthorized.json` ## Generating Fixtures diff --git a/internal/apply/apply.go b/internal/apply/apply.go index e7fff35..370f7c7 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -47,6 +47,7 @@ Supported asset types: - CheckRule (or PrometheusRule CRD) - SyntheticCheck - View + - Dash0RecordingRuleGroup If an asset exists, it will be updated. If it doesn't exist, it will be created.` + internal.CONFIG_HINT, Example: ` # Apply a single asset @@ -167,7 +168,7 @@ func runApply(ctx context.Context, flags *applyFlags) error { if doc.kind == "" { validationErrors = append(validationErrors, fmt.Sprintf("%s: missing 'kind' field", doc.location())) } else if !isValidKind(doc.kind) { - validationErrors = append(validationErrors, fmt.Sprintf("%s: unsupported kind %q (supported: Dashboard, PersesDashboard, CheckRule, PrometheusRule, SyntheticCheck, View)", doc.location(), doc.kind)) + validationErrors = append(validationErrors, fmt.Sprintf("%s: unsupported kind %q (supported: Dashboard, PersesDashboard, CheckRule, PrometheusRule, SyntheticCheck, View, Dash0RecordingRuleGroup)", doc.location(), doc.kind)) } } if len(validationErrors) > 0 { @@ -327,6 +328,14 @@ func parseDocumentHeader(data []byte) (kind, name, id string, err error) { name = dash0api.GetSyntheticCheckName(&check) id = dash0api.GetSyntheticCheckID(&check) + case "recordingrulegroup": + var group dash0api.RecordingRuleGroupDefinition + if err := sigsyaml.Unmarshal(data, &group); err != nil { + return "", "", "", fmt.Errorf("failed to decode document: %w", err) + } + name = asset.ExtractRecordingRuleGroupName(&group) + id = asset.ExtractRecordingRuleGroupID(&group) + case "prometheusrule": // We only need metadata (name + ID) here; the Metadata struct has no // time.Duration fields, so a partial unmarshal via sigsyaml is safe. @@ -536,7 +545,7 @@ func readDirectory(dirPath string) ([]assetDocument, error) { func isValidKind(kind string) bool { switch normalizeKind(kind) { - case "dashboard", "checkrule", "syntheticcheck", "view", "prometheusrule", "persesdashboard": + case "dashboard", "checkrule", "syntheticcheck", "view", "prometheusrule", "persesdashboard", "recordingrulegroup": return true default: return false @@ -620,6 +629,20 @@ func applyDocument(ctx context.Context, apiClient dash0api.Client, doc assetDocu } return []applyResult{{kind: doc.kind, name: result.Name, id: result.ID, action: applyAction(result.Action), before: result.Before, after: result.After}}, nil + case "recordingrulegroup": + var group dash0api.RecordingRuleGroupDefinition + if err := sigsyaml.Unmarshal(doc.raw, &group); err != nil { + return nil, fmt.Errorf("failed to parse Dash0RecordingRuleGroup: %w", err) + } + result, err := asset.ImportRecordingRuleGroup(ctx, apiClient, &group, dataset) + if err != nil { + return nil, client.HandleAPIError(err, client.ErrorContext{ + AssetType: "recording rule", + AssetName: group.Metadata.Name, + }) + } + 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) } diff --git a/internal/asset/diff.go b/internal/asset/diff.go index 349ece2..f03fcfd 100644 --- a/internal/asset/diff.go +++ b/internal/asset/diff.go @@ -51,6 +51,13 @@ func marshalForDiff(asset any) (string, error) { } dash0api.StripSyntheticCheckServerFields(&c) stripped = &c + case *dash0api.RecordingRuleGroupDefinition: + var g dash0api.RecordingRuleGroupDefinition + if err := sigsyaml.Unmarshal(jsonBytes, &g); err != nil { + return "", fmt.Errorf("failed to unmarshal recording rule: %w", err) + } + StripRecordingRuleGroupServerFields(&g) + stripped = &g default: stripped = asset } diff --git a/internal/asset/kind.go b/internal/asset/kind.go index a1fb1ad..68c7505 100644 --- a/internal/asset/kind.go +++ b/internal/asset/kind.go @@ -25,6 +25,8 @@ func KindDisplayName(kind string) string { return "PrometheusRule" case "persesdashboard": return "PersesDashboard" + case "recordingrulegroup": + return "Recording rule" default: return kind } diff --git a/internal/asset/recordingrulegroup.go b/internal/asset/recordingrulegroup.go new file mode 100644 index 0000000..f081483 --- /dev/null +++ b/internal/asset/recordingrulegroup.go @@ -0,0 +1,121 @@ +package asset + +import ( + "context" + + dash0api "github.com/dash0hq/dash0-api-client-go" +) + +// StripRecordingRuleGroupServerFields removes server-generated fields from a +// recording rule definition. Used by both Import (to avoid sending +// rejected fields to the API) and diff rendering (to suppress noise). +// +// Fields NOT stripped (user-specified, round-trippable): +// - dash0.com/origin — external identifier set by Terraform/operator/user +// - dash0.com/dataset — dataset routing, user-supplied +// - annotations.dash0.com/folder-path — optional UI folder path +// - annotations.dash0.com/sharing — optional sharing config +func StripRecordingRuleGroupServerFields(g *dash0api.RecordingRuleGroupDefinition) { + if g.Metadata.Labels != nil { + g.Metadata.Labels.Dash0Comid = nil + g.Metadata.Labels.Dash0Comversion = nil + g.Metadata.Labels.Dash0Comsource = nil + } + if g.Metadata.Annotations != nil { + g.Metadata.Annotations.Dash0ComcreatedAt = nil + g.Metadata.Annotations.Dash0ComdeletedAt = nil + g.Metadata.Annotations.Dash0ComupdatedAt = nil + } + g.Spec.Permissions = nil + g.Spec.PermittedActions = nil +} + +// InjectRecordingRuleGroupDataset injects the dataset into metadata.labels, which +// is how Create and Update receive the dataset (no query param for these endpoints). +// If dataset is nil or empty, the existing value in the file is left unchanged. +func InjectRecordingRuleGroupDataset(group *dash0api.RecordingRuleGroupDefinition, dataset *string) { + if dataset == nil || *dataset == "" { + return + } + if group.Metadata.Labels == nil { + group.Metadata.Labels = &dash0api.RecordingRuleGroupLabels{} + } + group.Metadata.Labels.Dash0Comdataset = dataset +} + +// InjectRecordingRuleGroupVersion copies the dash0.com/version label from source +// into group before an update call, to satisfy the API's optimistic concurrency check. +func InjectRecordingRuleGroupVersion(group, source *dash0api.RecordingRuleGroupDefinition) { + if source.Metadata.Labels == nil || source.Metadata.Labels.Dash0Comversion == nil { + return + } + if group.Metadata.Labels == nil { + group.Metadata.Labels = &dash0api.RecordingRuleGroupLabels{} + } + group.Metadata.Labels.Dash0Comversion = source.Metadata.Labels.Dash0Comversion +} + +// ImportRecordingRuleGroup checks existence by origin or ID, strips +// server-generated fields, injects the dataset into the body, and creates or +// updates the recording rule via the standard CRUD APIs. +func ImportRecordingRuleGroup(ctx context.Context, apiClient dash0api.Client, group *dash0api.RecordingRuleGroupDefinition, dataset *string) (ImportResult, error) { + StripRecordingRuleGroupServerFields(group) + + // Override dataset in body if --dataset flag was provided. + InjectRecordingRuleGroupDataset(group, dataset) + + action := ActionCreated + var before any + id := ExtractRecordingRuleGroupID(group) + if id != "" { + existing, err := apiClient.GetRecordingRuleGroup(ctx, id, dataset) + if err == nil { + action = ActionUpdated + before = existing + // Inject the current version for optimistic concurrency control. + InjectRecordingRuleGroupVersion(group, existing) + } else { + // Asset not found — clear the origin so the API creates a fresh asset. + group.Metadata.Labels.Dash0Comorigin = nil + id = "" + } + } + + var result *dash0api.RecordingRuleGroupDefinition + var err error + if action == ActionUpdated { + result, err = apiClient.UpdateRecordingRuleGroup(ctx, id, group) + } else { + result, err = apiClient.CreateRecordingRuleGroup(ctx, group) + } + if err != nil { + return ImportResult{}, err + } + + resultID := ExtractRecordingRuleGroupID(result) + return ImportResult{Name: ExtractRecordingRuleGroupName(result), ID: resultID, Action: action, Before: before, After: result}, nil +} + +// ExtractRecordingRuleGroupID extracts the external-facing ID from a recording +// rule definition. The origin label (set by Terraform/operator) is +// preferred; the internal UUID is used as a fallback. +func ExtractRecordingRuleGroupID(group *dash0api.RecordingRuleGroupDefinition) string { + if group.Metadata.Labels != nil { + if group.Metadata.Labels.Dash0Comorigin != nil && *group.Metadata.Labels.Dash0Comorigin != "" { + return *group.Metadata.Labels.Dash0Comorigin + } + if group.Metadata.Labels.Dash0Comid != nil && *group.Metadata.Labels.Dash0Comid != "" { + return *group.Metadata.Labels.Dash0Comid + } + } + return "" +} + +// ExtractRecordingRuleGroupName extracts the display name from a recording +// rule definition, falling back to metadata.name if no display name is set. +func ExtractRecordingRuleGroupName(group *dash0api.RecordingRuleGroupDefinition) string { + if group.Spec.Display.Name != "" { + return group.Spec.Display.Name + } + return group.Metadata.Name +} diff --git a/internal/asset/recordingrulegroup_test.go b/internal/asset/recordingrulegroup_test.go new file mode 100644 index 0000000..eb9afbf --- /dev/null +++ b/internal/asset/recordingrulegroup_test.go @@ -0,0 +1,145 @@ +package asset + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + dash0api "github.com/dash0hq/dash0-api-client-go" + "github.com/dash0hq/dash0-cli/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" +) + +func TestExtractRecordingRuleGroupID(t *testing.T) { + tests := []struct { + name string + group *dash0api.RecordingRuleGroupDefinition + want string + }{ + { + name: "nil labels", + group: &dash0api.RecordingRuleGroupDefinition{}, + want: "", + }, + { + name: "nil origin and nil id", + group: &dash0api.RecordingRuleGroupDefinition{ + Metadata: dash0api.RecordingRuleGroupMetadata{ + Labels: &dash0api.RecordingRuleGroupLabels{}, + }, + }, + want: "", + }, + { + name: "origin takes precedence over id", + group: &dash0api.RecordingRuleGroupDefinition{ + Metadata: dash0api.RecordingRuleGroupMetadata{ + Labels: &dash0api.RecordingRuleGroupLabels{ + Dash0Comid: strPtr("aaaa1111-1234-5678-abcd-000000000001"), + Dash0Comorigin: strPtr("tf_aaaa1111-1234-5678-abcd-000000000001"), + }, + }, + }, + want: "tf_aaaa1111-1234-5678-abcd-000000000001", + }, + { + name: "falls back to id when origin is empty", + group: &dash0api.RecordingRuleGroupDefinition{ + Metadata: dash0api.RecordingRuleGroupMetadata{ + Labels: &dash0api.RecordingRuleGroupLabels{ + Dash0Comid: strPtr("aaaa1111-1234-5678-abcd-000000000001"), + Dash0Comorigin: strPtr(""), + }, + }, + }, + want: "aaaa1111-1234-5678-abcd-000000000001", + }, + { + name: "only id set", + group: &dash0api.RecordingRuleGroupDefinition{ + Metadata: dash0api.RecordingRuleGroupMetadata{ + Labels: &dash0api.RecordingRuleGroupLabels{ + Dash0Comid: strPtr("bbbb2222-1234-5678-abcd-000000000002"), + }, + }, + }, + want: "bbbb2222-1234-5678-abcd-000000000002", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractRecordingRuleGroupID(tt.group) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestExtractRecordingRuleGroupName(t *testing.T) { + tests := []struct { + name string + group *dash0api.RecordingRuleGroupDefinition + want string + }{ + { + name: "display name takes precedence", + group: &dash0api.RecordingRuleGroupDefinition{ + Metadata: dash0api.RecordingRuleGroupMetadata{Name: "metadata-name"}, + Spec: dash0api.RecordingRuleGroupSpec{Display: dash0api.RecordingRuleGroupDisplay{Name: "Display Name"}}, + }, + want: "Display Name", + }, + { + name: "falls back to metadata name", + group: &dash0api.RecordingRuleGroupDefinition{ + Metadata: dash0api.RecordingRuleGroupMetadata{Name: "metadata-name"}, + }, + want: "metadata-name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractRecordingRuleGroupName(tt.group) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestYAMLRoundTrip_RecordingRuleGroup(t *testing.T) { + fixtureData := readFixture(t, testutil.FixtureRecordingRuleGroupsGetSuccess) + + var original dash0api.RecordingRuleGroupDefinition + require.NoError(t, json.Unmarshal(fixtureData, &original)) + + yamlData, err := yaml.Marshal(&original) + require.NoError(t, err) + + var roundTripped dash0api.RecordingRuleGroupDefinition + require.NoError(t, yaml.Unmarshal(yamlData, &roundTripped)) + + assertJSONEqual(t, &original, &roundTripped) +} + +func strPtr(s string) *string { + return &s +} + +func readFixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join(testutil.FixturesDir(), name)) + require.NoError(t, err) + return data +} + +func assertJSONEqual(t *testing.T, expected, actual any) { + t.Helper() + expectedJSON, err := json.Marshal(expected) + require.NoError(t, err) + actualJSON, err := json.Marshal(actual) + require.NoError(t, err) + assert.JSONEq(t, string(expectedJSON), string(actualJSON)) +} diff --git a/internal/recordingrulegroups/create.go b/internal/recordingrulegroups/create.go new file mode 100644 index 0000000..f4ba0c4 --- /dev/null +++ b/internal/recordingrulegroups/create.go @@ -0,0 +1,74 @@ +package recordingrulegroups + +import ( + "context" + "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" + "github.com/spf13/cobra" + sigsyaml "sigs.k8s.io/yaml" +) + +func newCreateCmd() *cobra.Command { + var flags asset.FileInputFlags + + cmd := &cobra.Command{ + Use: "create -f ", + Aliases: []string{"add"}, + Short: "Create a recording rule from a file", + Long: `Create a new recording rule from a YAML or JSON definition file. Use '-f -' to read from stdin.` + internal.CONFIG_HINT, + Example: ` # Create from a YAML file + dash0 recording-rules create -f recording-rule.yaml + + # Create from stdin + cat recording-rule.yaml | dash0 recording-rules create -f - + + # Validate without creating + dash0 recording-rules create -f recording-rule.yaml --dry-run`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCreate(cmd.Context(), &flags) + }, + } + + asset.RegisterFileInputFlags(cmd, &flags) + return cmd +} + +func runCreate(ctx context.Context, flags *asset.FileInputFlags) error { + var group dash0api.RecordingRuleGroupDefinition + if err := asset.ReadDefinition(flags.File, &group, os.Stdin); err != nil { + return fmt.Errorf("failed to read recording rule definition: %w", err) + } + + if flags.DryRun { + // Validate that it's valid YAML by marshaling + if _, err := sigsyaml.Marshal(&group); err != nil { + return fmt.Errorf("recording rule definition is not valid: %w", err) + } + fmt.Println("Dry run: recording rule definition is valid") + return nil + } + + asset.StripRecordingRuleGroupServerFields(&group) + asset.InjectRecordingRuleGroupDataset(&group, client.ResolveDataset(ctx, flags.Dataset)) + + apiClient, err := client.NewClientFromContext(ctx, flags.ApiUrl, flags.AuthToken) + if err != nil { + return err + } + + result, err := apiClient.CreateRecordingRuleGroup(ctx, &group) + if err != nil { + return client.HandleAPIError(err, client.ErrorContext{ + AssetType: "recording rule", + AssetName: asset.ExtractRecordingRuleGroupName(&group), + }) + } + + fmt.Printf("Recording rule %q created\n", asset.ExtractRecordingRuleGroupName(result)) + return nil +} diff --git a/internal/recordingrulegroups/delete.go b/internal/recordingrulegroups/delete.go new file mode 100644 index 0000000..6f8d4bf --- /dev/null +++ b/internal/recordingrulegroups/delete.go @@ -0,0 +1,65 @@ +package recordingrulegroups + +import ( + "context" + "fmt" + + "github.com/dash0hq/dash0-cli/internal" + "github.com/dash0hq/dash0-cli/internal/asset" + "github.com/dash0hq/dash0-cli/internal/client" + "github.com/dash0hq/dash0-cli/internal/confirmation" + "github.com/spf13/cobra" +) + +func newDeleteCmd() *cobra.Command { + var flags asset.DeleteFlags + + cmd := &cobra.Command{ + Use: "delete ", + Aliases: []string{"remove"}, + Short: "Delete a recording rule", + Long: `Delete a recording rule by its origin or ID. Use --force to skip the confirmation prompt.` + internal.CONFIG_HINT, + Example: ` # Delete with confirmation prompt + dash0 recording-rules delete + + # Delete without confirmation (for scripts and automation) + dash0 recording-rules delete --force`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDelete(cmd.Context(), args[0], &flags) + }, + } + + asset.RegisterDeleteFlags(cmd, &flags) + return cmd +} + +func runDelete(ctx context.Context, id string, flags *asset.DeleteFlags) error { + confirmed, err := confirmation.ConfirmDestructiveOperation( + fmt.Sprintf("Are you sure you want to delete recording rule %q? [y/N]: ", id), + flags.Force, + ) + if err != nil { + return err + } + if !confirmed { + fmt.Println("Deletion cancelled") + return nil + } + + apiClient, err := client.NewClientFromContext(ctx, flags.ApiUrl, flags.AuthToken) + if err != nil { + return err + } + + err = apiClient.DeleteRecordingRuleGroup(ctx, id, client.ResolveDataset(ctx, flags.Dataset)) + if err != nil { + return client.HandleAPIError(err, client.ErrorContext{ + AssetType: "recording rule", + AssetID: id, + }) + } + + fmt.Printf("Recording rule %q deleted\n", id) + return nil +} diff --git a/internal/recordingrulegroups/get.go b/internal/recordingrulegroups/get.go new file mode 100644 index 0000000..1355df4 --- /dev/null +++ b/internal/recordingrulegroups/get.go @@ -0,0 +1,82 @@ +package recordingrulegroups + +import ( + "context" + "fmt" + "os" + + "github.com/dash0hq/dash0-cli/internal" + "github.com/dash0hq/dash0-cli/internal/asset" + "github.com/dash0hq/dash0-cli/internal/client" + "github.com/dash0hq/dash0-cli/internal/output" + "github.com/spf13/cobra" +) + +func newGetCmd() *cobra.Command { + var flags asset.GetFlags + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get a recording rule by ID", + Long: `Retrieve a recording rule definition by its origin or ID.` + internal.CONFIG_HINT, + Example: ` # Show recording rule summary + dash0 recording-rules get + + # Export as YAML (suitable for re-applying) + dash0 recording-rules get -o yaml > recording-rule.yaml + + # Export as JSON + dash0 recording-rules get -o json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runGet(cmd.Context(), args[0], &flags) + }, + } + + asset.RegisterGetFlags(cmd, &flags) + return cmd +} + +func runGet(ctx context.Context, id string, flags *asset.GetFlags) error { + apiUrl := client.ResolveApiUrl(ctx, flags.ApiUrl) + apiClient, err := client.NewClientFromContext(ctx, flags.ApiUrl, flags.AuthToken) + if err != nil { + return err + } + + group, err := apiClient.GetRecordingRuleGroup(ctx, id, client.ResolveDataset(ctx, flags.Dataset)) + if err != nil { + return client.HandleAPIError(err, client.ErrorContext{ + AssetType: "recording rule", + AssetID: id, + }) + } + + format, err := output.ParseFormat(flags.Output) + if err != nil { + return err + } + + formatter := output.NewFormatter(format, os.Stdout) + + switch format { + case output.FormatJSON, output.FormatYAML: + return formatter.Print(group) + default: + fmt.Printf("Name: %s\n", asset.ExtractRecordingRuleGroupName(group)) + fmt.Printf("Enabled: %t\n", group.Spec.Enabled) + fmt.Printf("Interval: %s\n", group.Spec.Interval) + if group.Metadata.Labels != nil { + if group.Metadata.Labels.Dash0Comdataset != nil { + fmt.Printf("Dataset: %s\n", *group.Metadata.Labels.Dash0Comdataset) + } + if group.Metadata.Labels.Dash0Comorigin != nil { + fmt.Printf("Origin: %s\n", *group.Metadata.Labels.Dash0Comorigin) + } + } + if deeplinkURL := asset.DeeplinkURL(apiUrl, "recording rule", id); deeplinkURL != "" { + fmt.Printf("URL: %s\n", deeplinkURL) + } + return nil + } +} diff --git a/internal/recordingrulegroups/integration_test.go b/internal/recordingrulegroups/integration_test.go new file mode 100644 index 0000000..94fb295 --- /dev/null +++ b/internal/recordingrulegroups/integration_test.go @@ -0,0 +1,74 @@ +//go:build integration + +package recordingrulegroups + +import ( + "net/http" + "testing" + + "github.com/dash0hq/dash0-cli/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + apiPathRecordingRuleGroups = "/api/recording-rule-groups" + fixtureListSuccess = "recordingrulegroups/list_success.json" + fixtureListEmpty = "recordingrulegroups/list_empty.json" + fixtureGetSuccess = "recordingrulegroups/get_success.json" + fixtureUnauthorized = "dashboards/error_unauthorized.json" +) + +func TestListRecordingRuleGroups_JSONFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathRecordingRuleGroups, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureListSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewRecordingRuleGroupsCmd() + 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 recording rule definitions + assert.Contains(t, output, `"kind": "Dash0RecordingRuleGroup"`) + assert.Contains(t, output, `"metadata"`) + assert.Contains(t, output, `"spec"`) + assert.Contains(t, output, `"HTTP Metrics"`) +} + +func TestListRecordingRuleGroups_YAMLFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathRecordingRuleGroups, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureListSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewRecordingRuleGroupsCmd() + 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 recording rule definitions as multi-document YAML + assert.Contains(t, output, "kind: Dash0RecordingRuleGroup") + assert.Contains(t, output, "metadata:") + assert.Contains(t, output, "spec:") + assert.Contains(t, output, "HTTP Metrics") + // Multiple documents should be separated by --- + assert.Contains(t, output, "---") +} diff --git a/internal/recordingrulegroups/list.go b/internal/recordingrulegroups/list.go new file mode 100644 index 0000000..ef6eb12 --- /dev/null +++ b/internal/recordingrulegroups/list.go @@ -0,0 +1,155 @@ +package recordingrulegroups + +import ( + "context" + "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" + "github.com/dash0hq/dash0-cli/internal/output" + "github.com/spf13/cobra" +) + +func newListCmd() *cobra.Command { + var flags asset.ListFlags + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List recording rules", + Long: `List all recording rules in the specified dataset.` + internal.CONFIG_HINT, + Example: ` # List recording rules (default: up to 50) + dash0 recording-rules list + + # Output as YAML for backup or version control + dash0 recording-rules list -o yaml > recording-rules.yaml + + # Output as JSON for scripting + dash0 recording-rules list -o json + + # Output as CSV (pipe-friendly) + dash0 recording-rules list -o csv + + # List without the header row (pipe-friendly) + dash0 recording-rules list --skip-header`, + RunE: func(cmd *cobra.Command, args []string) error { + return runList(cmd.Context(), &flags) + }, + } + + asset.RegisterListFlags(cmd, &flags) + return cmd +} + +func runList(ctx context.Context, flags *asset.ListFlags) error { + if err := output.ValidateSkipHeader(flags.SkipHeader, flags.Output); err != nil { + return err + } + + apiClient, err := client.NewClientFromContext(ctx, flags.ApiUrl, flags.AuthToken) + if err != nil { + return err + } + + dataset := client.ResolveDataset(ctx, flags.Dataset) + iter := apiClient.ListRecordingRuleGroupsIter(ctx, dataset) + + // The list endpoint returns full definitions — no second fetch needed. + var groups []*dash0api.RecordingRuleGroupDefinition + count := 0 + for iter.Next() { + groups = append(groups, iter.Current()) + count++ + if !flags.All && flags.Limit > 0 && count >= flags.Limit { + break + } + } + + if err := iter.Err(); err != nil { + return client.HandleAPIError(err, client.ErrorContext{ + AssetType: "recording rule", + }) + } + + format, err := output.ParseFormat(flags.Output) + if err != nil { + return err + } + + formatter := output.NewFormatter(format, os.Stdout, output.WithSkipHeader(flags.SkipHeader)) + + switch format { + case output.FormatJSON, output.FormatYAML: + definitions := make([]interface{}, len(groups)) + for i, g := range groups { + definitions[i] = g + } + if format == output.FormatYAML { + return formatter.PrintMultiDocYAML(definitions) + } + return formatter.PrintJSON(definitions) + default: + return printRecordingRuleGroupTable(formatter, groups, format) + } +} + +func printRecordingRuleGroupTable(f *output.Formatter, groups []*dash0api.RecordingRuleGroupDefinition, format output.Format) error { + columns := []output.Column{ + {Header: internal.HEADER_NAME, Width: 40, Value: func(item interface{}) string { + g := item.(*dash0api.RecordingRuleGroupDefinition) + return asset.ExtractRecordingRuleGroupName(g) + }}, + {Header: internal.HEADER_ID, Width: 36, Value: func(item interface{}) string { + g := item.(*dash0api.RecordingRuleGroupDefinition) + return asset.ExtractRecordingRuleGroupID(g) + }}, + } + + if format == output.FormatWide || format == output.FormatCSV { + columns = append(columns, + output.Column{Header: "ENABLED", Width: 7, Value: func(item interface{}) string { + g := item.(*dash0api.RecordingRuleGroupDefinition) + if g.Spec.Enabled { + return "true" + } + return "false" + }}, + output.Column{Header: "INTERVAL", Width: 10, Value: func(item interface{}) string { + g := item.(*dash0api.RecordingRuleGroupDefinition) + return string(g.Spec.Interval) + }}, + output.Column{Header: internal.HEADER_DATASET, Width: 15, Value: func(item interface{}) string { + g := item.(*dash0api.RecordingRuleGroupDefinition) + if g.Metadata.Labels != nil && g.Metadata.Labels.Dash0Comdataset != nil { + return *g.Metadata.Labels.Dash0Comdataset + } + return "" + }}, + output.Column{Header: internal.HEADER_ORIGIN, Width: 20, Value: func(item interface{}) string { + g := item.(*dash0api.RecordingRuleGroupDefinition) + if g.Metadata.Labels != nil && g.Metadata.Labels.Dash0Comorigin != nil { + return *g.Metadata.Labels.Dash0Comorigin + } + return "" + }}, + ) + } + + if len(groups) == 0 { + fmt.Println("No recording rules found.") + return nil + } + + data := make([]interface{}, len(groups)) + for i, g := range groups { + data[i] = g + } + + if format == output.FormatCSV { + return f.PrintCSV(columns, data) + } + return f.PrintTable(columns, data) +} diff --git a/internal/recordingrulegroups/recordingrulegroups_cmd.go b/internal/recordingrulegroups/recordingrulegroups_cmd.go new file mode 100644 index 0000000..bbc663c --- /dev/null +++ b/internal/recordingrulegroups/recordingrulegroups_cmd.go @@ -0,0 +1,21 @@ +package recordingrulegroups + +import "github.com/spf13/cobra" + +// NewRecordingRuleGroupsCmd creates the recording-rules parent command. +func NewRecordingRuleGroupsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "recording-rules", + Aliases: []string{"rr"}, + Short: "Manage Dash0 recording rules", + Long: `Create, list, get, update, and delete recording rules in Dash0`, + } + + cmd.AddCommand(newListCmd()) + cmd.AddCommand(newGetCmd()) + cmd.AddCommand(newCreateCmd()) + cmd.AddCommand(newUpdateCmd()) + cmd.AddCommand(newDeleteCmd()) + + return cmd +} diff --git a/internal/recordingrulegroups/update.go b/internal/recordingrulegroups/update.go new file mode 100644 index 0000000..e18a19a --- /dev/null +++ b/internal/recordingrulegroups/update.go @@ -0,0 +1,97 @@ +package recordingrulegroups + +import ( + "context" + "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" + "github.com/spf13/cobra" +) + +func newUpdateCmd() *cobra.Command { + var flags asset.FileInputFlags + + cmd := &cobra.Command{ + Use: "update [id] -f ", + Short: "Update a recording rule from a file", + Long: `Update an existing recording rule from a YAML or JSON definition file. Use '-f -' to read from stdin. + +If the ID argument is omitted, the ID is extracted from the file content.` + internal.CONFIG_HINT, + Example: ` # Update a recording rule from a file + dash0 recording-rules update -f recording-rule.yaml + + # Update using the ID from the file + dash0 recording-rules update -f recording-rule.yaml + + # Export, edit, and update + dash0 recording-rules get -o yaml > recording-rule.yaml + # edit recording-rule.yaml + dash0 recording-rules update -f recording-rule.yaml`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runUpdate(cmd.Context(), args, &flags) + }, + } + + asset.RegisterFileInputFlags(cmd, &flags) + return cmd +} + +func runUpdate(ctx context.Context, args []string, flags *asset.FileInputFlags) error { + var group dash0api.RecordingRuleGroupDefinition + if err := asset.ReadDefinition(flags.File, &group, os.Stdin); err != nil { + return fmt.Errorf("failed to read recording rule definition: %w", err) + } + asset.StripRecordingRuleGroupServerFields(&group) + + var id string + fileID := asset.ExtractRecordingRuleGroupID(&group) + if len(args) == 1 { + id = args[0] + if fileID != "" && fileID != id { + return fmt.Errorf("the ID argument %q does not match the ID in the file %q", id, fileID) + } + } else { + id = fileID + if id == "" { + return fmt.Errorf("no recording rule ID provided as argument, and the file does not contain an ID") + } + } + + apiClient, err := client.NewClientFromContext(ctx, flags.ApiUrl, flags.AuthToken) + if err != nil { + return err + } + + dataset := client.ResolveDataset(ctx, flags.Dataset) + + before, err := apiClient.GetRecordingRuleGroup(ctx, id, dataset) + if err != nil { + return client.HandleAPIError(err, client.ErrorContext{ + AssetType: "recording rule", + AssetID: id, + }) + } + + if flags.DryRun { + return asset.PrintDiff(os.Stdout, "Recording rule", group.Metadata.Name, before, &group) + } + + // Inject dataset and version before the PUT. + asset.InjectRecordingRuleGroupDataset(&group, dataset) + asset.InjectRecordingRuleGroupVersion(&group, before) + result, err := apiClient.UpdateRecordingRuleGroup(ctx, id, &group) + if err != nil { + return client.HandleAPIError(err, client.ErrorContext{ + AssetType: "recording rule", + AssetID: id, + AssetName: asset.ExtractRecordingRuleGroupName(&group), + }) + } + + return asset.PrintDiff(os.Stdout, "Recording rule", asset.ExtractRecordingRuleGroupName(result), before, result) +} diff --git a/internal/recordingrulegroups/update_integration_test.go b/internal/recordingrulegroups/update_integration_test.go new file mode 100644 index 0000000..366610d --- /dev/null +++ b/internal/recordingrulegroups/update_integration_test.go @@ -0,0 +1,75 @@ +//go:build integration + +package recordingrulegroups + +import ( + "os" + "path/filepath" + "testing" + + "github.com/dash0hq/dash0-cli/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testAuthToken = "auth_test_token" + +func TestUpdateRecordingRuleGroup_IDMismatch(t *testing.T) { + testutil.SetupTestEnv(t) + + tmpDir := t.TempDir() + yamlFile := filepath.Join(tmpDir, "group.yaml") + err := os.WriteFile(yamlFile, []byte(`kind: Dash0RecordingRuleGroup +metadata: + name: http-metrics + labels: + dash0.com/origin: file-origin-1111-2222-3333-444444444444 +spec: + display: + name: HTTP Metrics + enabled: true + interval: 1m + rules: + - record: http_request_rate + expression: rate(http_requests_total[5m]) +`), 0644) + require.NoError(t, err) + + cmd := NewRecordingRuleGroupsCmd() + cmd.SetArgs([]string{"update", "arg-origin-aaaa-bbbb-cccc-dddddddddddd", "-f", yamlFile, "--api-url", "http://unused", "--auth-token", testAuthToken}) + + cmdErr := cmd.Execute() + + require.Error(t, cmdErr) + assert.Contains(t, cmdErr.Error(), "does not match") + assert.Contains(t, cmdErr.Error(), "arg-origin-aaaa-bbbb-cccc-dddddddddddd") + assert.Contains(t, cmdErr.Error(), "file-origin-1111-2222-3333-444444444444") +} + +func TestUpdateRecordingRuleGroup_NoIDAnywhere(t *testing.T) { + testutil.SetupTestEnv(t) + + tmpDir := t.TempDir() + yamlFile := filepath.Join(tmpDir, "group.yaml") + err := os.WriteFile(yamlFile, []byte(`kind: Dash0RecordingRuleGroup +metadata: + name: http-metrics +spec: + display: + name: HTTP Metrics + enabled: true + interval: 1m + rules: + - record: http_request_rate + expression: rate(http_requests_total[5m]) +`), 0644) + require.NoError(t, err) + + cmd := NewRecordingRuleGroupsCmd() + cmd.SetArgs([]string{"update", "-f", yamlFile, "--api-url", "http://unused", "--auth-token", testAuthToken}) + + cmdErr := cmd.Execute() + + require.Error(t, cmdErr) + assert.Contains(t, cmdErr.Error(), "no recording rule ID provided as argument, and the file does not contain an ID") +} diff --git a/internal/testutil/fixtures/recordingrulegroups/error_not_found.json b/internal/testutil/fixtures/recordingrulegroups/error_not_found.json new file mode 100644 index 0000000..ea85f17 --- /dev/null +++ b/internal/testutil/fixtures/recordingrulegroups/error_not_found.json @@ -0,0 +1,7 @@ +{ + "error": { + "code": 404, + "message": "Not Found: The requested recording rule group does not exist or is inaccessible to you.", + "traceId": "a2e6c82f1a6389934e41f346b442764c" + } +} diff --git a/internal/testutil/fixtures/recordingrulegroups/get_success.json b/internal/testutil/fixtures/recordingrulegroups/get_success.json new file mode 100644 index 0000000..0788baa --- /dev/null +++ b/internal/testutil/fixtures/recordingrulegroups/get_success.json @@ -0,0 +1,52 @@ +{ + "kind": "Dash0RecordingRuleGroup", + "metadata": { + "name": "http_metrics", + "labels": { + "dash0.com/dataset": "default", + "dash0.com/id": "aaaa1111-1234-5678-abcd-000000000001", + "dash0.com/origin": "tf_aaaa1111-1234-5678-abcd-000000000001", + "dash0.com/source": "terraform", + "dash0.com/version": "3" + }, + "annotations": { + "dash0.com/created-at": "2026-01-10T10:00:00Z", + "dash0.com/updated-at": "2026-02-15T14:30:00Z", + "dash0.com/folder-path": "/infrastructure/http", + "dash0.com/sharing": "team:team_01abc,user:alice@example.com" + } + }, + "spec": { + "display": { + "name": "HTTP Metrics" + }, + "enabled": true, + "interval": "1m", + "rules": [ + { + "record": "http_requests_total:rate5m", + "expression": "rate(http_requests_total[5m])", + "labels": { + "env": "production" + } + }, + { + "record": "http_errors_total:rate5m", + "expression": "rate(http_requests_total{status=~\"5..\"}[5m])", + "labels": { + "env": "production" + } + } + ], + "permissions": [ + { + "actions": [ + "recording_rule_group:read", + "recording_rule_group:write", + "recording_rule_group:delete" + ], + "role": "admin" + } + ] + } +} diff --git a/internal/testutil/fixtures/recordingrulegroups/import_success.json b/internal/testutil/fixtures/recordingrulegroups/import_success.json new file mode 100644 index 0000000..1154395 --- /dev/null +++ b/internal/testutil/fixtures/recordingrulegroups/import_success.json @@ -0,0 +1,52 @@ +{ + "kind": "Dash0RecordingRuleGroup", + "metadata": { + "name": "http_metrics", + "labels": { + "dash0.com/dataset": "default", + "dash0.com/id": "aaaa1111-1234-5678-abcd-000000000001", + "dash0.com/origin": "tf_aaaa1111-1234-5678-abcd-000000000001", + "dash0.com/source": "terraform", + "dash0.com/version": "1" + }, + "annotations": { + "dash0.com/created-at": "2026-01-10T10:00:00Z", + "dash0.com/updated-at": "2026-01-10T10:00:00Z", + "dash0.com/folder-path": "/infrastructure/http", + "dash0.com/sharing": "team:team_01abc,user:alice@example.com" + } + }, + "spec": { + "display": { + "name": "HTTP Metrics" + }, + "enabled": true, + "interval": "1m", + "rules": [ + { + "record": "http_requests_total:rate5m", + "expression": "rate(http_requests_total[5m])", + "labels": { + "env": "production" + } + }, + { + "record": "http_errors_total:rate5m", + "expression": "rate(http_requests_total{status=~\"5..\"}[5m])", + "labels": { + "env": "production" + } + } + ], + "permissions": [ + { + "actions": [ + "recording_rule_group:read", + "recording_rule_group:write", + "recording_rule_group:delete" + ], + "role": "admin" + } + ] + } +} diff --git a/internal/testutil/fixtures/recordingrulegroups/list_empty.json b/internal/testutil/fixtures/recordingrulegroups/list_empty.json new file mode 100644 index 0000000..c9cf1cd --- /dev/null +++ b/internal/testutil/fixtures/recordingrulegroups/list_empty.json @@ -0,0 +1,3 @@ +{ + "recordingRuleGroups": [] +} diff --git a/internal/testutil/fixtures/recordingrulegroups/list_success.json b/internal/testutil/fixtures/recordingrulegroups/list_success.json new file mode 100644 index 0000000..d298041 --- /dev/null +++ b/internal/testutil/fixtures/recordingrulegroups/list_success.json @@ -0,0 +1,54 @@ +{ + "recordingRuleGroups": [ + { + "kind": "Dash0RecordingRuleGroup", + "metadata": { + "name": "http_metrics", + "labels": { + "dash0.com/dataset": "default", + "dash0.com/id": "aaaa1111-1234-5678-abcd-000000000001", + "dash0.com/origin": "tf_aaaa1111-1234-5678-abcd-000000000001", + "dash0.com/version": "1" + } + }, + "spec": { + "display": { + "name": "HTTP Metrics" + }, + "enabled": true, + "interval": "1m", + "rules": [ + { + "record": "http_request_rate", + "expression": "rate(http_requests_total[5m])" + } + ] + } + }, + { + "kind": "Dash0RecordingRuleGroup", + "metadata": { + "name": "service_metrics", + "labels": { + "dash0.com/dataset": "default", + "dash0.com/id": "bbbb2222-1234-5678-abcd-000000000002", + "dash0.com/origin": "tf_bbbb2222-1234-5678-abcd-000000000002", + "dash0.com/version": "1" + } + }, + "spec": { + "display": { + "name": "Service Metrics" + }, + "enabled": true, + "interval": "5m", + "rules": [ + { + "record": "service_error_rate", + "expression": "rate(service_errors_total[5m])" + } + ] + } + } + ] +} diff --git a/internal/testutil/mockserver.go b/internal/testutil/mockserver.go index cd931db..b970e93 100644 --- a/internal/testutil/mockserver.go +++ b/internal/testutil/mockserver.go @@ -65,6 +65,13 @@ const ( FixtureSyntheticChecksImportSuccess = "syntheticchecks/import_success.json" FixtureSyntheticChecksNotFound = "syntheticchecks/error_not_found.json" + // Recording rules fixtures + FixtureRecordingRuleGroupsListSuccess = "recordingrulegroups/list_success.json" + FixtureRecordingRuleGroupsListEmpty = "recordingrulegroups/list_empty.json" + FixtureRecordingRuleGroupsGetSuccess = "recordingrulegroups/get_success.json" + FixtureRecordingRuleGroupsImportSuccess = "recordingrulegroups/import_success.json" + FixtureRecordingRuleGroupsNotFound = "recordingrulegroups/error_not_found.json" + // Teams fixtures FixtureTeamsListSuccess = "teams/list_success.json" FixtureTeamsListEmpty = "teams/list_empty.json" @@ -486,6 +493,47 @@ func (m *MockServer) WithSyntheticChecksDelete() *MockServer { }) } +// WithRecordingRuleGroupsList sets up the mock server to return a list of recording rules. +func (m *MockServer) WithRecordingRuleGroupsList(fixture string) *MockServer { + return m.On(http.MethodGet, "/api/recording-rule-groups", MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixture, + }) +} + +// WithRecordingRuleGroupsGet sets up the mock server to return a recording rule by origin or ID. +func (m *MockServer) WithRecordingRuleGroupsGet(fixture string) *MockServer { + return m.OnPattern(http.MethodGet, regexp.MustCompile(`^/api/recording-rule-groups/[^/]+$`), MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixture, + }) +} + +// WithRecordingRuleGroupsCreate sets up the mock server to accept recording rule creation. +func (m *MockServer) WithRecordingRuleGroupsCreate(fixture string) *MockServer { + return m.On(http.MethodPost, "/api/recording-rule-groups", MockResponse{ + StatusCode: http.StatusCreated, + BodyFile: fixture, + Validator: RequireHeaders, + }) +} + +// WithRecordingRuleGroupsUpdate sets up the mock server to accept recording rule updates. +func (m *MockServer) WithRecordingRuleGroupsUpdate(fixture string) *MockServer { + return m.OnPattern(http.MethodPut, regexp.MustCompile(`^/api/recording-rule-groups/[^/]+$`), MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixture, + Validator: RequireHeaders, + }) +} + +// WithRecordingRuleGroupsDelete sets up the mock server to accept recording rule deletion. +func (m *MockServer) WithRecordingRuleGroupsDelete() *MockServer { + return m.OnPattern(http.MethodDelete, regexp.MustCompile(`^/api/recording-rule-groups/[^/]+$`), MockResponse{ + StatusCode: http.StatusNoContent, + }) +} + // WithNotFound sets up any unmatched route to return 404. func (m *MockServer) WithNotFound(fixture string) *MockServer { return m.OnDefault(func(w http.ResponseWriter, r *http.Request) { diff --git a/test/roundtrip/fixtures/recording-rule.yaml b/test/roundtrip/fixtures/recording-rule.yaml new file mode 100644 index 0000000..f42e121 --- /dev/null +++ b/test/roundtrip/fixtures/recording-rule.yaml @@ -0,0 +1,13 @@ +kind: Dash0RecordingRuleGroup +metadata: + name: roundtrip_test_recording_rule +spec: + display: + name: Roundtrip Test Recording Rule + enabled: true + interval: 1m + rules: + - record: http_requests_total:rate5m + expression: rate(http_requests_total[5m]) + labels: + env: test diff --git a/test/roundtrip/run_all.sh b/test/roundtrip/run_all.sh index 806e659..24331ee 100755 --- a/test/roundtrip/run_all.sh +++ b/test/roundtrip/run_all.sh @@ -26,6 +26,7 @@ for script in \ "${SCRIPT_DIR}/test_check_rule_roundtrip.sh" \ "${SCRIPT_DIR}/test_synthetic_check_roundtrip.sh" \ "${SCRIPT_DIR}/test_view_roundtrip.sh" \ + "${SCRIPT_DIR}/test_recording_rule_roundtrip.sh" \ "${SCRIPT_DIR}/test_apply_dashboard_idempotency.sh" \ "${SCRIPT_DIR}/test_apply_check_rule_idempotency.sh" \ "${SCRIPT_DIR}/test_apply_view_idempotency.sh" \ diff --git a/test/roundtrip/test_recording_rule_roundtrip.sh b/test/roundtrip/test_recording_rule_roundtrip.sh new file mode 100755 index 0000000..4081dc5 --- /dev/null +++ b/test/roundtrip/test_recording_rule_roundtrip.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DASH0="${SCRIPT_DIR}/../../build/dash0" +FIXTURES="${SCRIPT_DIR}/fixtures" +FIXTURE="${FIXTURES}/recording-rule.yaml" +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +ASSET_NAME=$(yq '.spec.display.name' "$FIXTURE") + +echo "=== Recording rule round-trip test ===" +echo "Asset name: $ASSET_NAME" + +# Step 1: Create from fixture +echo "--- Step 1: Create recording rule from fixture ---" +if ! CREATE_OUTPUT=$("$DASH0" recording-rules create -f "$FIXTURE"); then + echo "FAIL: recording-rules create failed" + exit 1 +fi +echo "$CREATE_OUTPUT" +if ! echo "$CREATE_OUTPUT" | grep -q "$ASSET_NAME"; then + echo "FAIL: create output does not mention asset name '$ASSET_NAME'" + exit 1 +fi + +# Step 2: List recording rules and find the created asset by name +echo "--- Step 2: List recording rules and find created asset ---" +if ! LIST_JSON=$("$DASH0" recording-rules list -o json); then + echo "FAIL: recording-rules list -o json failed" + exit 1 +fi +ID=$(echo "$LIST_JSON" | jq -r --arg name "$ASSET_NAME" '[.[] | select(.spec.display.name == $name)][0].metadata.labels["dash0.com/id"] // empty') +if [ -z "$ID" ]; then + echo "FAIL: Could not find created recording rule '$ASSET_NAME' in list" + exit 1 +fi +echo "Created recording rule ID: $ID" + +# Step 3: Get by ID +echo "--- Step 3: Get recording rule by ID ---" +if ! "$DASH0" recording-rules get "$ID"; then + echo "FAIL: recording-rules get failed" + exit 1 +fi + +# Step 4: Export to YAML +echo "--- Step 4: Export recording rule to YAML ---" +if ! "$DASH0" recording-rules get "$ID" -o yaml > "${TMPDIR}/exported.yaml"; then + echo "FAIL: recording-rules get -o yaml failed" + exit 1 +fi +echo "Exported to ${TMPDIR}/exported.yaml" + +# Step 5: Re-import via apply (round-trip) +echo "--- Step 5: Re-import exported YAML via apply ---" +if ! "$DASH0" apply -f "${TMPDIR}/exported.yaml"; then + echo "FAIL: apply failed" + exit 1 +fi + +# Step 6: Delete +echo "--- Step 6: Delete recording rule ---" +if ! "$DASH0" recording-rules delete "$ID" --force; then + echo "FAIL: recording-rules delete failed" + exit 1 +fi + +# Step 7: Verify deletion +echo "--- Step 7: Verify deletion ---" +if ! LIST_JSON=$("$DASH0" recording-rules list -o json); then + echo "FAIL: recording-rules list -o json failed" + exit 1 +fi +if echo "$LIST_JSON" | jq -e --arg id "$ID" '.[] | select(.metadata.labels["dash0.com/id"] == $id)' > /dev/null 2>&1; then + echo "FAIL: Recording rule '$ID' still exists after deletion" + exit 1 +fi + +echo "=== Recording rule round-trip test PASSED ==="