diff --git a/.chloggen/perses-crd.yaml b/.chloggen/perses-crd.yaml new file mode 100644 index 0000000..8d43ef7 --- /dev/null +++ b/.chloggen/perses-crd.yaml @@ -0,0 +1,27 @@ +# 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: Accept PersesDashboard CRD files in `apply` and `dashboards create` + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [85] + +# (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: | + PersesDashboard CRDs (perses.dev/v1alpha1 and perses.dev/v1alpha2) are now accepted as input. + +# 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/CLAUDE.md b/CLAUDE.md index 156f944..075a28f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,3 +23,8 @@ Detailed guidelines are split into focused documents: - @docs/testing.md — test strategies, integration tests, fixtures, mock server - @docs/github-actions.md — how to create GitHub actions based on the `dash0` CLI, existing actions and their maintenance - @docs/changelog-maintenance.md — when and how to create changelog entries + +## GitHub issues + +When creating GitHub issues, describe **what** and **why** — not **how**. +Issues should only contain the problem statement, user-facing behavior, and acceptance criteria. diff --git a/README.md b/README.md index 32529c8..f84ae8e 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ Currently only HTTP OTLP endpoints are supported. Apply asset definitions from a file, directory, or stdin. The input may contain multiple YAML documents separated by `---`. -Supported asset types: `Dashboard`, `CheckRule`, `SyntheticCheck`, and `View`. +Supported asset types: `Dashboard`, `PersesDashboard`, `CheckRule`, `SyntheticCheck`, and `View`. ```bash dash0 apply -f assets.yaml @@ -157,6 +157,7 @@ dash0 dashboards list dash0 dashboards get dash0 dashboards get -o yaml dash0 dashboards create -f dashboard.yaml +dash0 dashboards create -f persesdashboard.yaml dash0 dashboards update [id] -f dashboard.yaml dash0 dashboards delete [--force] ``` @@ -173,6 +174,7 @@ dash0 check-rules update [id] -f rule.yaml dash0 check-rules delete [--force] ``` +Both `apply` and `dashboards create` also accept PersesDashboard CRD files. Both `apply` and `check-rules create` also accept PrometheusRule CRD files. ### Synthetic checks diff --git a/docs/cli-naming-conventions.md b/docs/cli-naming-conventions.md index b54f7c6..dbef7ab 100644 --- a/docs/cli-naming-conventions.md +++ b/docs/cli-naming-conventions.md @@ -12,46 +12,46 @@ Use the word "asset" consistently where appropriate. ## Standard CRUD Subcommands for Assets All asset commands (`dashboards`, `check-rules`, `views`, `synthetic-checks`) use these subcommands: -| Subcommand | Alias | Description | -|------------|----------|-------------| -| `list` | `ls` | List all assets | -| `get` | - | Get a single asset by ID | -| `create` | `add` | Create a new asset from a file | +| Subcommand | Alias | Description | +|------------|----------|--------------------------------------| +| `list` | `ls` | List all assets | +| `get` | - | Get a single asset by ID | +| `create` | `add` | Create a new asset from a file | | `update` | - | Update an existing asset from a file | -| `delete` | `remove` | Delete an asset by ID | +| `delete` | `remove` | Delete an asset by ID | ## Config Profiles Subcommands The `config profiles` command uses: -| Subcommand | Alias | Description | -|------------|----------|-------------| -| `list` | `ls` | List all profiles | -| `create` | `add` | Create a new profile | -| `update` | - | Update an existing profile | -| `delete` | `remove` | Delete a profile | -| `select` | `activate` | Set the active profile | +| Subcommand | Alias | Description | +|------------|------------|----------------------------| +| `list` | `ls` | List all profiles | +| `create` | `add` | Create a new profile | +| `update` | - | Update an existing profile | +| `delete` | `remove` | Delete a profile | +| `select` | `activate` | Set the active profile | ## Teams Subcommands (experimental) The `teams` command manages organizational teams (not assets — no dataset, no YAML input, no `apply`): -| Subcommand | Alias | Description | -|------------|----------|-------------| -| `list` | `ls` | List all teams | -| `get` | - | Get team details (members + accessible assets) | -| `create` | `add` | Create a team (flag-based, not file-based) | -| `update` | - | Update team display settings | -| `delete` | `remove` | Delete a team | -| `list-members` | - | List members of a team | -| `add-members` | - | Add members to a team | -| `remove-members` | - | Remove members from a team | +| Subcommand | Alias | Description | +|------------------|----------|-------------------------------------------------| +| `list` | `ls` | List all teams | +| `get` | - | Get team details (members + accessible assets) | +| `create` | `add` | Create a team (flag-based, not file-based) | +| `update` | - | Update team display settings | +| `delete` | `remove` | Delete a team | +| `list-members` | - | List members of a team | +| `add-members` | - | Add members to a team | +| `remove-members` | - | Remove members from a team | ## Members Subcommands (experimental) The `members` command manages organization membership: -| Subcommand | Alias | Description | -|------------|----------|-------------| -| `list` | `ls` | List all organization members | -| `invite` | `add` | Invite members by email | +| Subcommand | Alias | Description | +|------------|----------|--------------------------------------| +| `list` | `ls` | List all organization members | +| `invite` | `add` | Invite members by email | | `remove` | `delete` | Remove members from the organization | ## Aliases @@ -64,13 +64,14 @@ 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 | +| Kind identifier | Display name | +|--------------------|-----------------| +| `Dashboard` | Dashboard | +| `CheckRule` | Check rule | +| `SyntheticCheck` | Synthetic check | +| `View` | View | +| `PrometheusRule` | PrometheusRule | +| `PersesDashboard` | PersesDashboard | For example: `Check rule "High Error Rate" created successfully`, not `CheckRule "High Error Rate" created successfully`. @@ -85,4 +86,5 @@ For example: `Check rule "High Error Rate" created successfully`, not `CheckRule The `apply` command and the individual CRUD subcommands (e.g., `check-rules create`, `dashboards create`) must have the same expressiveness. Any asset format accepted by `apply` must also be accepted by the corresponding `create` command, and vice versa. For example, `dash0 apply -f prometheusrule.yaml` and `dash0 check-rules create -f prometheusrule.yaml` both accept PrometheusRule CRD files. +Similarly, `dash0 apply -f persesdashboard.yaml` and `dash0 dashboards create -f persesdashboard.yaml` both accept PersesDashboard CRD files. Shared parsing and import logic lives in `internal/asset/` so that both code paths stay in sync. diff --git a/docs/commands.md b/docs/commands.md index aa5159d..18fddc6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -267,6 +267,13 @@ $ dash0 dashboards create -f dashboard.yaml Dashboard "My Dashboard" created successfully ``` +`dashboards create` also accepts PersesDashboard CRD files (`perses.dev/v1alpha1` and `perses.dev/v1alpha2`). + +```bash +$ dash0 dashboards create -f persesdashboard.yaml +Dashboard "My Perses Dashboard" created +``` + `check-rules create` also accepts PrometheusRule CRD files. Each alerting rule in the CRD is created as a separate check rule (recording rules are skipped): @@ -324,7 +331,7 @@ Aliases: `remove` | Asset type | Command | Notes | |------------|---------|-------| -| Dashboards | `dash0 dashboards ` | | +| Dashboards | `dash0 dashboards ` | `create` also accepts PersesDashboard CRD files | | Check rules | `dash0 check-rules ` | `create` also accepts PrometheusRule CRD files | | Synthetic checks | `dash0 synthetic-checks ` | | | Views | `dash0 views ` | | @@ -348,9 +355,14 @@ 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`, `CheckRule`, `PrometheusRule`, `SyntheticCheck`, `View`. +Supported `kind` values: `Dashboard`, `PersesDashboard`, `CheckRule`, `PrometheusRule`, `SyntheticCheck`, `View`. A single file may contain multiple documents separated by `---`. +> [!NOTE] +> The `-f` flag accepts a single path. +> Do not use shell glob patterns like `-f assets/*` — the shell expands the glob into multiple arguments and only the first file is passed to `-f`. +> Use `-f assets/` (the directory) instead. + Examples: ```bash @@ -388,6 +400,20 @@ spec: name: Production Overview ``` +PersesDashboard (Perses CRD, converted to a Dashboard on import): + +```yaml +apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: my-perses-dashboard +spec: + display: + name: My Perses Dashboard + duration: 5m + panels: {} +``` + Check rule: ```yaml diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 99102fa..35f886b 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -42,7 +42,7 @@ Each document must have a "kind" field specifying the asset type. Use '-f -' to 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; if any document fails validation, no changes are made. Supported asset types: - - Dashboard + - Dashboard (or PersesDashboard CRD) - CheckRule (or PrometheusRule CRD) - SyntheticCheck - View @@ -66,6 +66,9 @@ If an asset exists, it will be updated. If it doesn't exist, it will be created. # Validate a directory without applying dash0 apply -f dashboards/ --dry-run`, RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return fmt.Errorf("unexpected arguments: %s\nTo apply multiple files, pass a directory with -f instead of a glob pattern", strings.Join(args, " ")) + } if flags.File == "" { return fmt.Errorf("file is required; use -f to specify the file (use '-' for stdin)") } @@ -161,7 +164,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, CheckRule, PrometheusRule, SyntheticCheck, View)", doc.location(), doc.kind)) + validationErrors = append(validationErrors, fmt.Sprintf("%s: unsupported kind %q (supported: Dashboard, PersesDashboard, CheckRule, PrometheusRule, SyntheticCheck, View)", doc.location(), doc.kind)) } } if len(validationErrors) > 0 { @@ -321,6 +324,14 @@ func parseDocumentHeader(data []byte) (kind, name, id string, err error) { name = asset.ExtractPrometheusRuleName(&promRule) id = asset.ExtractPrometheusRuleID(&promRule) + case "persesdashboard": + var perses asset.PersesDashboard + if err := sigsyaml.Unmarshal(data, &perses); err != nil { + return "", "", "", fmt.Errorf("failed to decode document: %w", err) + } + name = asset.ExtractPersesDashboardName(&perses) + id = asset.ExtractPersesDashboardID(&perses) + default: var raw map[string]any if err := sigsyaml.Unmarshal(data, &raw); err != nil { @@ -510,7 +521,7 @@ func readDirectory(dirPath string) ([]assetDocument, error) { func isValidKind(kind string) bool { switch normalizeKind(kind) { - case "dashboard", "checkrule", "syntheticcheck", "view", "prometheusrule": + case "dashboard", "checkrule", "syntheticcheck", "view", "prometheusrule", "persesdashboard": return true default: return false @@ -562,6 +573,20 @@ func applyDocument(ctx context.Context, apiClient dash0api.Client, doc assetDocu } return applyPrometheusRule(ctx, apiClient, &promRule, dataset) + case "persesdashboard": + var perses asset.PersesDashboard + if err := sigsyaml.Unmarshal(doc.raw, &perses); err != nil { + return nil, fmt.Errorf("failed to parse PersesDashboard: %w", err) + } + result, err := asset.ImportPersesDashboard(ctx, apiClient, &perses, dataset) + if err != nil { + return nil, client.HandleAPIError(err, client.ErrorContext{ + AssetType: "dashboard", + AssetName: asset.ExtractPersesDashboardName(&perses), + }) + } + return []applyResult{{kind: "Dashboard", name: result.Name, id: result.ID, action: applyAction(result.Action)}}, nil + case "syntheticcheck": var check dash0api.SyntheticCheckDefinition if err := sigsyaml.Unmarshal(doc.raw, &check); err != nil { diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go index 90b5251..2df963e 100644 --- a/internal/apply/apply_test.go +++ b/internal/apply/apply_test.go @@ -30,6 +30,8 @@ func TestNormalizeKind(t *testing.T) { {"synthetic-check", "syntheticcheck"}, {"View", "view"}, {"Dash0View", "view"}, + {"PersesDashboard", "persesdashboard"}, + {"persesdashboard", "persesdashboard"}, } for _, tt := range tests { @@ -56,6 +58,8 @@ func TestIsValidKind(t *testing.T) { "view", "Dash0Dashboard", "Dash0View", + "PersesDashboard", + "persesdashboard", } for _, kind := range validKinds { @@ -259,6 +263,45 @@ spec: assert.Equal(t, "PrometheusRule", docs[0].kind) } +func TestPersesDashboardParsing(t *testing.T) { + yaml := `apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: test-perses-dashboard + labels: + dash0.com/id: test-perses-id +spec: + display: + name: Test Perses Dashboard + duration: 5m + panels: {} +` + docs, err := readMultiDocumentYAML("-", strings.NewReader(yaml)) + require.NoError(t, err) + require.Len(t, docs, 1) + assert.Equal(t, "PersesDashboard", docs[0].kind) + assert.Equal(t, "Test Perses Dashboard", docs[0].name) + assert.Equal(t, "test-perses-id", docs[0].id) +} + +func TestPersesDashboardParsing_V1Alpha2(t *testing.T) { + yaml := `apiVersion: perses.dev/v1alpha2 +kind: PersesDashboard +metadata: + name: test-v1alpha2 +spec: + config: + display: + name: V1Alpha2 Dashboard + duration: 10m +` + docs, err := readMultiDocumentYAML("-", strings.NewReader(yaml)) + require.NoError(t, err) + require.Len(t, docs, 1) + assert.Equal(t, "PersesDashboard", docs[0].kind) + assert.Equal(t, "V1Alpha2 Dashboard", docs[0].name) +} + func TestReadMultiDocumentYAML_FromBuffer(t *testing.T) { yaml := `kind: Dashboard metadata: @@ -479,6 +522,33 @@ metadata: expectedName: "test-rules", expectedId: "prom-rule-id", }, + { + name: "PersesDashboard: display name and dash0.com/id label", + yaml: `apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: my-perses-dashboard + labels: + dash0.com/id: perses-dashboard-id +spec: + display: + name: My Perses Dashboard +`, + expectedName: "My Perses Dashboard", + expectedId: "perses-dashboard-id", + }, + { + name: "PersesDashboard: metadata.name as fallback, no ID", + yaml: `apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: fallback-perses-name +spec: + duration: 5m +`, + expectedName: "fallback-perses-name", + expectedId: "", + }, { name: "CheckRule without ID", yaml: `kind: CheckRule @@ -574,6 +644,20 @@ func TestParseDocumentHeader(t *testing.T) { expectedName: "some-name", expectedId: "", }, + { + name: "PersesDashboard: display name and label ID", + yaml: "apiVersion: perses.dev/v1alpha1\nkind: PersesDashboard\nmetadata:\n name: perses-name\n labels:\n dash0.com/id: perses-id\nspec:\n display:\n name: Perses Display Name\n", + expectedKind: "PersesDashboard", + expectedName: "Perses Display Name", + expectedId: "perses-id", + }, + { + name: "PersesDashboard: metadata.name fallback without display name", + yaml: "apiVersion: perses.dev/v1alpha1\nkind: PersesDashboard\nmetadata:\n name: perses-fallback\nspec:\n duration: 5m\n", + expectedKind: "PersesDashboard", + expectedName: "perses-fallback", + expectedId: "", + }, { name: "CheckRule inferred from name+expression when kind is missing", yaml: "name: Exported Rule\nexpression: up == 0\nenabled: true\n", diff --git a/internal/apply/integration_test.go b/internal/apply/integration_test.go index 321fdcc..4fa7bf5 100644 --- a/internal/apply/integration_test.go +++ b/internal/apply/integration_test.go @@ -510,6 +510,95 @@ spec: assert.Contains(t, output, "created") } +func TestApply_PersesDashboard_Created(t *testing.T) { + testutil.SetupTestEnv(t) + + tmpDir := t.TempDir() + yamlFile := filepath.Join(tmpDir, "persesdashboard.yaml") + err := os.WriteFile(yamlFile, []byte(`apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: test-perses-dashboard +spec: + display: + name: Test Perses Dashboard + duration: 5m + panels: {} +`), 0644) + require.NoError(t, err) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureDashboardsNotFound, + }) + server.WithDashboardImport(testutil.FixtureDashboardsImportSuccess) + + cmd := NewApplyCmd() + cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + // PersesDashboard is converted to a Dashboard + assert.Contains(t, output, "Dashboard") + assert.Contains(t, output, "created") + + // Verify the import request contains the Perses spec fields + importReq := findImportRequest(server.Requests(), apiPathImportDashboard) + require.NotNil(t, importReq, "expected an import request for dashboard") + body := string(importReq.Body) + assert.Contains(t, body, "Test Perses Dashboard") + assert.Contains(t, body, "5m") +} + +func TestApply_PersesDashboard_V1Alpha2(t *testing.T) { + testutil.SetupTestEnv(t) + + tmpDir := t.TempDir() + yamlFile := filepath.Join(tmpDir, "persesdashboard-v1alpha2.yaml") + err := os.WriteFile(yamlFile, []byte(`apiVersion: perses.dev/v1alpha2 +kind: PersesDashboard +metadata: + name: v1alpha2-dashboard +spec: + config: + display: + name: V1Alpha2 Dashboard + duration: 10m +`), 0644) + require.NoError(t, err) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureDashboardsNotFound, + }) + server.WithDashboardImport(testutil.FixtureDashboardsImportSuccess) + + cmd := NewApplyCmd() + cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.Contains(t, output, "Dashboard") + assert.Contains(t, output, "created") + + // Verify the spec.config wrapper was unwrapped + importReq := findImportRequest(server.Requests(), apiPathImportDashboard) + require.NotNil(t, importReq, "expected an import request for dashboard") + body := string(importReq.Body) + assert.Contains(t, body, "V1Alpha2 Dashboard") + assert.Contains(t, body, "10m") +} + func TestApply_Directory_MultipleFiles(t *testing.T) { testutil.SetupTestEnv(t) diff --git a/internal/asset/kind.go b/internal/asset/kind.go index b91425f..8333cac 100644 --- a/internal/asset/kind.go +++ b/internal/asset/kind.go @@ -46,6 +46,8 @@ func KindDisplayName(kind string) string { return "View" case "prometheusrule": return "PrometheusRule" + case "persesdashboard": + return "PersesDashboard" default: return kind } diff --git a/internal/asset/persesdashboard.go b/internal/asset/persesdashboard.go new file mode 100644 index 0000000..a56e5bd --- /dev/null +++ b/internal/asset/persesdashboard.go @@ -0,0 +1,127 @@ +package asset + +import ( + "context" + + dash0api "github.com/dash0hq/dash0-api-client-go" +) + +// PersesDashboard represents the Perses Operator PersesDashboard CRD +// (perses.dev/v1alpha1 and perses.dev/v1alpha2). +type PersesDashboard struct { + APIVersion string `yaml:"apiVersion" json:"apiVersion"` + Kind string `yaml:"kind" json:"kind"` + Metadata PersesDashboardMetadata `yaml:"metadata" json:"metadata"` + Spec map[string]interface{} `yaml:"spec" json:"spec"` +} + +// PersesDashboardMetadata contains metadata for a PersesDashboard. +type PersesDashboardMetadata struct { + Name string `yaml:"name,omitempty" json:"name,omitempty"` + Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"` + Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"` + Annotations map[string]string `yaml:"annotations,omitempty" json:"annotations,omitempty"` +} + +// ConvertToDashboard converts a PersesDashboard CRD into a Dash0 +// DashboardDefinition. It normalizes v1alpha1/v1alpha2 differences (the +// v1alpha2 CRD wraps the spec in a "config" key) and ensures a display name +// is set, falling back to metadata.name. +func ConvertToDashboard(perses *PersesDashboard) *dash0api.DashboardDefinition { + spec := perses.Spec + if spec == nil { + spec = make(map[string]interface{}) + } + + // Normalize v1alpha1/v1alpha2: if spec.config exists, unwrap it. + // v1alpha2 wraps the dashboard content in spec.config; v1alpha1 puts it + // directly under spec. After normalization both look the same. + if configRaw, ok := spec["config"]; ok { + if config, ok := configRaw.(map[string]interface{}); ok { + spec = config + } + } + + // Ensure display section exists + displayRaw, hasDisplay := spec["display"] + if !hasDisplay { + spec["display"] = map[string]interface{}{ + "name": perses.Metadata.Name, + } + } else if display, ok := displayRaw.(map[string]interface{}); ok { + // Set display.name to metadata.name if missing + if _, hasName := display["name"]; !hasName { + display["name"] = perses.Metadata.Name + } + } + + displayName := extractDisplayName(spec) + if displayName == "" { + displayName = perses.Metadata.Name + } + + dashboard := &dash0api.DashboardDefinition{ + Kind: dash0api.Dashboard, + Metadata: dash0api.DashboardMetadata{ + Name: displayName, + }, + Spec: spec, + } + + // Copy dash0.com/id from labels into dash0Extensions.id + if perses.Metadata.Labels != nil { + if id := perses.Metadata.Labels["dash0.com/id"]; id != "" { + dashboard.Metadata.Dash0Extensions = &dash0api.DashboardMetadataExtensions{ + Id: &id, + } + } + } + + return dashboard +} + +// extractDisplayName reads spec.display.name from a dashboard spec map. +func extractDisplayName(spec map[string]interface{}) string { + display, ok := spec["display"].(map[string]interface{}) + if !ok { + return "" + } + name, ok := display["name"].(string) + if !ok { + return "" + } + return name +} + +// ImportPersesDashboard converts a PersesDashboard CRD to a Dash0 dashboard +// and imports it. It returns the import result (created or updated). +func ImportPersesDashboard(ctx context.Context, apiClient dash0api.Client, perses *PersesDashboard, dataset *string) (ImportResult, error) { + dashboard := ConvertToDashboard(perses) + return ImportDashboard(ctx, apiClient, dashboard, dataset) +} + +// ExtractPersesDashboardName returns the display name from the Perses spec, +// falling back to metadata.name. +func ExtractPersesDashboardName(perses *PersesDashboard) string { + if perses.Spec != nil { + // Check after normalization: handle both v1alpha1 and v1alpha2 + spec := perses.Spec + if configRaw, ok := spec["config"]; ok { + if config, ok := configRaw.(map[string]interface{}); ok { + spec = config + } + } + if name := extractDisplayName(spec); name != "" { + return name + } + } + return perses.Metadata.Name +} + +// ExtractPersesDashboardID returns the dash0.com/id label value if present. +func ExtractPersesDashboardID(perses *PersesDashboard) string { + if perses.Metadata.Labels != nil { + return perses.Metadata.Labels["dash0.com/id"] + } + return "" +} diff --git a/internal/asset/persesdashboard_test.go b/internal/asset/persesdashboard_test.go new file mode 100644 index 0000000..98d3134 --- /dev/null +++ b/internal/asset/persesdashboard_test.go @@ -0,0 +1,205 @@ +package asset + +import ( + "testing" + + dash0api "github.com/dash0hq/dash0-api-client-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConvertToDashboard_WithDisplayName(t *testing.T) { + perses := &PersesDashboard{ + APIVersion: "perses.dev/v1alpha1", + Kind: "PersesDashboard", + Metadata: PersesDashboardMetadata{ + Name: "my-perses-dashboard", + }, + Spec: map[string]interface{}{ + "display": map[string]interface{}{ + "name": "My Perses Dashboard", + }, + "duration": "5m", + "panels": map[string]interface{}{}, + }, + } + + dashboard := ConvertToDashboard(perses) + + assert.Equal(t, dash0api.Dashboard, dashboard.Kind) + assert.Equal(t, "My Perses Dashboard", dashboard.Metadata.Name) + assert.Equal(t, "My Perses Dashboard", ExtractDashboardDisplayName(dashboard)) + assert.Equal(t, "5m", dashboard.Spec["duration"]) + assert.Nil(t, dashboard.Metadata.Dash0Extensions) +} + +func TestConvertToDashboard_WithoutDisplayName(t *testing.T) { + perses := &PersesDashboard{ + APIVersion: "perses.dev/v1alpha1", + Kind: "PersesDashboard", + Metadata: PersesDashboardMetadata{ + Name: "fallback-name", + }, + Spec: map[string]interface{}{ + "duration": "5m", + }, + } + + dashboard := ConvertToDashboard(perses) + + assert.Equal(t, dash0api.Dashboard, dashboard.Kind) + assert.Equal(t, "fallback-name", dashboard.Metadata.Name) + assert.Equal(t, "fallback-name", ExtractDashboardDisplayName(dashboard)) +} + +func TestConvertToDashboard_WithDisplaySectionButNoName(t *testing.T) { + perses := &PersesDashboard{ + APIVersion: "perses.dev/v1alpha1", + Kind: "PersesDashboard", + Metadata: PersesDashboardMetadata{ + Name: "metadata-name", + }, + Spec: map[string]interface{}{ + "display": map[string]interface{}{ + "description": "A dashboard without a display name", + }, + }, + } + + dashboard := ConvertToDashboard(perses) + + assert.Equal(t, "metadata-name", dashboard.Metadata.Name) + assert.Equal(t, "metadata-name", ExtractDashboardDisplayName(dashboard)) +} + +func TestConvertToDashboard_V1Alpha2_WithConfigWrapper(t *testing.T) { + perses := &PersesDashboard{ + APIVersion: "perses.dev/v1alpha2", + Kind: "PersesDashboard", + Metadata: PersesDashboardMetadata{ + Name: "v1alpha2-dashboard", + }, + Spec: map[string]interface{}{ + "config": map[string]interface{}{ + "display": map[string]interface{}{ + "name": "V1Alpha2 Dashboard", + }, + "duration": "10m", + "panels": map[string]interface{}{}, + }, + }, + } + + dashboard := ConvertToDashboard(perses) + + assert.Equal(t, dash0api.Dashboard, dashboard.Kind) + assert.Equal(t, "V1Alpha2 Dashboard", dashboard.Metadata.Name) + assert.Equal(t, "V1Alpha2 Dashboard", ExtractDashboardDisplayName(dashboard)) + assert.Equal(t, "10m", dashboard.Spec["duration"]) + // The config wrapper should be unwrapped + _, hasConfig := dashboard.Spec["config"] + assert.False(t, hasConfig, "spec.config should be unwrapped") +} + +func TestConvertToDashboard_WithDash0ID(t *testing.T) { + perses := &PersesDashboard{ + APIVersion: "perses.dev/v1alpha1", + Kind: "PersesDashboard", + Metadata: PersesDashboardMetadata{ + Name: "dashboard-with-id", + Labels: map[string]string{ + "dash0.com/id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + }, + }, + Spec: map[string]interface{}{ + "display": map[string]interface{}{ + "name": "Dashboard With ID", + }, + }, + } + + dashboard := ConvertToDashboard(perses) + + require.NotNil(t, dashboard.Metadata.Dash0Extensions) + require.NotNil(t, dashboard.Metadata.Dash0Extensions.Id) + assert.Equal(t, "a1b2c3d4-5678-90ab-cdef-1234567890ab", *dashboard.Metadata.Dash0Extensions.Id) +} + +func TestConvertToDashboard_NilSpec(t *testing.T) { + perses := &PersesDashboard{ + APIVersion: "perses.dev/v1alpha1", + Kind: "PersesDashboard", + Metadata: PersesDashboardMetadata{ + Name: "nil-spec-dashboard", + }, + Spec: nil, + } + + dashboard := ConvertToDashboard(perses) + + assert.Equal(t, dash0api.Dashboard, dashboard.Kind) + assert.Equal(t, "nil-spec-dashboard", dashboard.Metadata.Name) +} + +func TestExtractPersesDashboardName_FromDisplayName(t *testing.T) { + perses := &PersesDashboard{ + Metadata: PersesDashboardMetadata{ + Name: "metadata-name", + }, + Spec: map[string]interface{}{ + "display": map[string]interface{}{ + "name": "Display Name", + }, + }, + } + + assert.Equal(t, "Display Name", ExtractPersesDashboardName(perses)) +} + +func TestExtractPersesDashboardName_FromMetadataName(t *testing.T) { + perses := &PersesDashboard{ + Metadata: PersesDashboardMetadata{ + Name: "metadata-name", + }, + Spec: map[string]interface{}{}, + } + + assert.Equal(t, "metadata-name", ExtractPersesDashboardName(perses)) +} + +func TestExtractPersesDashboardName_V1Alpha2(t *testing.T) { + perses := &PersesDashboard{ + Metadata: PersesDashboardMetadata{ + Name: "metadata-name", + }, + Spec: map[string]interface{}{ + "config": map[string]interface{}{ + "display": map[string]interface{}{ + "name": "V1Alpha2 Name", + }, + }, + }, + } + + assert.Equal(t, "V1Alpha2 Name", ExtractPersesDashboardName(perses)) +} + +func TestExtractPersesDashboardID(t *testing.T) { + perses := &PersesDashboard{ + Metadata: PersesDashboardMetadata{ + Labels: map[string]string{ + "dash0.com/id": "test-id", + }, + }, + } + + assert.Equal(t, "test-id", ExtractPersesDashboardID(perses)) +} + +func TestExtractPersesDashboardID_NoLabels(t *testing.T) { + perses := &PersesDashboard{ + Metadata: PersesDashboardMetadata{}, + } + + assert.Equal(t, "", ExtractPersesDashboardID(perses)) +} diff --git a/internal/dashboards/create.go b/internal/dashboards/create.go index 7df8699..9751dc0 100644 --- a/internal/dashboards/create.go +++ b/internal/dashboards/create.go @@ -4,12 +4,14 @@ import ( "context" "fmt" "os" + "strings" 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" + "sigs.k8s.io/yaml" ) func newCreateCmd() *cobra.Command { @@ -18,11 +20,16 @@ func newCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create -f ", Aliases: []string{"add"}, - Short: "Create a dashboard from a file", - Long: `Create a new dashboard from a YAML or JSON definition file. Use '-f -' to read from stdin.` + internal.CONFIG_HINT, + Short: "Create a dashboard from a file", + Long: `Create a new dashboard from a YAML or JSON definition file. Use '-f -' to read from stdin. + +Accepts both plain Dashboard definitions and PersesDashboard CRD files. When a PersesDashboard CRD is provided, it is converted to a Dash0 dashboard.` + internal.CONFIG_HINT, Example: ` # Create from a YAML file dash0 dashboards create -f dashboard.yaml + # Create from a PersesDashboard CRD file + dash0 dashboards create -f persesdashboard.yaml + # Create from stdin cat dashboard.yaml | dash0 dashboards create -f - @@ -38,8 +45,21 @@ func newCreateCmd() *cobra.Command { } func runCreate(ctx context.Context, flags *asset.FileInputFlags) error { + data, err := asset.ReadRawInput(flags.File, os.Stdin) + if err != nil { + return fmt.Errorf("failed to read dashboard definition: %w", err) + } + + kind := strings.ToLower(asset.DetectKind(data)) + if kind == "persesdashboard" { + return createFromPersesDashboard(ctx, flags, data) + } + return createFromDashboard(ctx, flags, data) +} + +func createFromDashboard(ctx context.Context, flags *asset.FileInputFlags, data []byte) error { var dashboard dash0api.DashboardDefinition - if err := asset.ReadDefinition(flags.File, &dashboard, os.Stdin); err != nil { + if err := yaml.Unmarshal(data, &dashboard); err != nil { return fmt.Errorf("failed to read dashboard definition: %w", err) } @@ -61,6 +81,34 @@ func runCreate(ctx context.Context, flags *asset.FileInputFlags) error { }) } - fmt.Printf("Dashboard %q %s successfully\n", result.Name, result.Action) + fmt.Printf("Dashboard %q %s\n", result.Name, result.Action) + return nil +} + +func createFromPersesDashboard(ctx context.Context, flags *asset.FileInputFlags, data []byte) error { + var perses asset.PersesDashboard + if err := yaml.Unmarshal(data, &perses); err != nil { + return fmt.Errorf("failed to read PersesDashboard definition: %w", err) + } + + if flags.DryRun { + fmt.Println("Dry run: PersesDashboard definition is valid") + return nil + } + + apiClient, err := client.NewClientFromContext(ctx, flags.ApiUrl, flags.AuthToken) + if err != nil { + return err + } + + result, importErr := asset.ImportPersesDashboard(ctx, apiClient, &perses, client.ResolveDataset(ctx, flags.Dataset)) + if importErr != nil { + return client.HandleAPIError(importErr, client.ErrorContext{ + AssetType: "dashboard", + AssetName: asset.ExtractPersesDashboardName(&perses), + }) + } + + fmt.Printf("Dashboard %q %s\n", result.Name, result.Action) return nil }