From b4edfabfb42726ebf2626d9cd6c56ae25600a437 Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Wed, 22 Jul 2026 10:27:59 +0100 Subject: [PATCH 1/2] feat: add `dash0 slos` command group for SLO IaC support Add create/get/list/update/delete subcommands for service level objectives (SLOs), backed by the /api/slos endpoint using OpenSLO v1 documents (apiVersion: openslo/v1, kind: SLO). Dataset-scoped; ID at metadata.labels."dash0.com/id"; deep-link URL support. Also wires SLO into `apply`, adds integration tests against the mock server, a roundtrip test, docs, and a changelog entry. Part of cross-facility SLO IaC support (API client, CLI, Terraform provider, Kubernetes operator). Note: builds against the local dash0-api-client-go worktree via a go.mod replace directive while the API client change is unpublished. The replace must be removed and the require version upgraded once the API client is released. Closes #206 Co-Authored-By: Claude Opus 4.8 (1M context) --- .chloggen/slos.yaml | 28 ++ cmd/dash0/main.go | 2 + docs/cli-naming-conventions.md | 5 +- docs/commands.md | 47 +- go.mod | 2 + go.sum | 2 - internal/apply/apply.go | 27 +- internal/asset/kind.go | 2 + internal/asset/slo.go | 43 ++ internal/slos/create.go | 66 +++ internal/slos/delete.go | 66 +++ internal/slos/get.go | 84 ++++ internal/slos/integration_test.go | 430 ++++++++++++++++++ internal/slos/list.go | 134 ++++++ internal/slos/slos_cmd.go | 20 + internal/slos/update.go | 93 ++++ .../fixtures/slos/create_success.json | 61 +++ .../fixtures/slos/error_not_found.json | 7 + .../testutil/fixtures/slos/get_success.json | 61 +++ .../testutil/fixtures/slos/list_empty.json | 1 + .../testutil/fixtures/slos/list_success.json | 63 +++ .../fixtures/slos/update_success.json | 61 +++ test/roundtrip/fixtures/slo.yaml | 33 ++ test/roundtrip/run_all.sh | 1 + test/roundtrip/test_slo_roundtrip.sh | 83 ++++ 25 files changed, 1413 insertions(+), 9 deletions(-) create mode 100644 .chloggen/slos.yaml create mode 100644 internal/asset/slo.go create mode 100644 internal/slos/create.go create mode 100644 internal/slos/delete.go create mode 100644 internal/slos/get.go create mode 100644 internal/slos/integration_test.go create mode 100644 internal/slos/list.go create mode 100644 internal/slos/slos_cmd.go create mode 100644 internal/slos/update.go create mode 100644 internal/testutil/fixtures/slos/create_success.json create mode 100644 internal/testutil/fixtures/slos/error_not_found.json create mode 100644 internal/testutil/fixtures/slos/get_success.json create mode 100644 internal/testutil/fixtures/slos/list_empty.json create mode 100644 internal/testutil/fixtures/slos/list_success.json create mode 100644 internal/testutil/fixtures/slos/update_success.json create mode 100644 test/roundtrip/fixtures/slo.yaml create mode 100755 test/roundtrip/test_slo_roundtrip.sh diff --git a/.chloggen/slos.yaml b/.chloggen/slos.yaml new file mode 100644 index 0000000..1d544d1 --- /dev/null +++ b/.chloggen/slos.yaml @@ -0,0 +1,28 @@ +# 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: slos + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Add `dash0 slos` commands to manage service level objectives (SLOs) as code + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [206] + +# (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: | + SLO documents use the OpenSLO v1 format (`apiVersion: openslo/v1`, `kind: SLO`) and are dataset-scoped via `--dataset`. + The commands support `create`, `list`, `get`, `update`, and `delete`, and SLOs can also be managed through `dash0 apply`. + +# 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/cmd/dash0/main.go b/cmd/dash0/main.go index 9cfdc79..b3f5b2c 100644 --- a/cmd/dash0/main.go +++ b/cmd/dash0/main.go @@ -27,6 +27,7 @@ import ( "github.com/dash0hq/dash0-cli/internal/otlp" "github.com/dash0hq/dash0-cli/internal/rawapi" "github.com/dash0hq/dash0-cli/internal/recordingrules" + "github.com/dash0hq/dash0-cli/internal/slos" "github.com/dash0hq/dash0-cli/internal/spamfilters" "github.com/dash0hq/dash0-cli/internal/syntheticchecks" "github.com/dash0hq/dash0-cli/internal/teams" @@ -84,6 +85,7 @@ func init() { rootCmd.AddCommand(notificationchannels.NewNotificationChannelsCmd()) rootCmd.AddCommand(otlp.NewOtlpCmd()) rootCmd.AddCommand(recordingrules.NewRecordingRulesCmd()) + rootCmd.AddCommand(slos.NewSlosCmd()) rootCmd.AddCommand(spamfilters.NewSpamFiltersCmd()) rootCmd.AddCommand(syntheticchecks.NewSyntheticChecksCmd()) rootCmd.AddCommand(teams.NewTeamsCmd()) diff --git a/docs/cli-naming-conventions.md b/docs/cli-naming-conventions.md index bc9ad07..effa8c7 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`, `recording-rules`, `notification-channels`, `spam-filters` +- Use **plural form**: `dashboards`, `views`, `check-rules`, `synthetic-checks`, `slos`, `recording-rules`, `notification-channels`, `spam-filters` - Use **kebab-case** for multi-word names: `check-rules`, `synthetic-checks`, `recording-rules`, `notification-channels`, `spam-filters` - Group related functionality: `config profiles` for profile management ## Standard CRUD Subcommands for Assets -All asset commands (`dashboards`, `check-rules`, `views`, `synthetic-checks`, `recording-rules`, `notification-channels`, `spam-filters`) use these subcommands: +All asset commands (`dashboards`, `check-rules`, `views`, `synthetic-checks`, `slos`, `recording-rules`, `notification-channels`, `spam-filters`) use these subcommands: | Subcommand | Alias | Description | |------------|----------|--------------------------------------| @@ -80,6 +80,7 @@ In user-facing output (success messages, dry-run listings, error messages), use | `Dashboard` | Dashboard | | `CheckRule` | Check rule | | `SyntheticCheck` | Synthetic check | +| `SLO` | SLO | | `View` | View | | `PrometheusRule` | PrometheusRule | | `PersesDashboard` | PersesDashboard | diff --git a/docs/commands.md b/docs/commands.md index 9273abd..2091b41 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -13,7 +13,7 @@ Each category has distinct patterns for flags, output, and behavior. |----------|----------|-----------------| | [Authentication](#authentication) | `login`, `logout` | Browser-based OAuth 2.0 + PKCE; per-profile | | [Configuration](#configuration) | `config profiles`, `config show` | Profile management, no API calls | -| [Asset CRUD](#asset-crud-commands) | `dashboards`, `views`, `check-rules`, `synthetic-checks`, `recording-rules`, `notification-channels`, `spam-filters`, `apply` | File-based input, `--dry-run`, five standard subcommands | +| [Asset CRUD](#asset-crud-commands) | `dashboards`, `views`, `check-rules`, `synthetic-checks`, `slos`, `recording-rules`, `notification-channels`, `spam-filters`, `apply` | File-based input, `--dry-run`, five standard subcommands | | [Query](#query-commands) | `logs query`, `spans query`, `traces get`, `metrics instant`, `failed-checks query` | Time range, filters | | [Send](#send-commands) | `logs send`, `spans send` | OTLP-based, repeatable attribute flags | | [Daemon](#daemon-commands) | `otlp proxy` | Long-running, signal-driven shutdown, experimental | @@ -484,7 +484,7 @@ Profile: prod (from DASH0_PROFILE environment variable) Asset CRUD commands create, list, get, update, and delete Dash0 assets. Dash0 calls dashboards, views, synthetic checks, and check rules "assets" (not "resources", which is an overloaded term in OpenTelemetry). -All seven asset types (`dashboards`, `check-rules`, `synthetic-checks`, `views`, `recording-rules`, `notification-channels`, `spam-filters`) share the same CRUD subcommands. +All eight asset types (`dashboards`, `check-rules`, `synthetic-checks`, `slos`, `views`, `recording-rules`, `notification-channels`, `spam-filters`) share the same CRUD subcommands. The examples below use `dashboards`, but the same patterns apply to every asset type. ### `list` @@ -679,6 +679,7 @@ Aliases: `remove` | 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 ` | | +| SLOs | `dash0 slos ` | Dataset-scoped; documents use the OpenSLO v1 format (`apiVersion: openslo/v1`, `kind: SLO`) | | Views | `dash0 views ` | | | Recording rules | `dash0 recording-rules ` | Uses PrometheusRule CRD format | | Notification channels | `dash0 notification-channels ` | Organization-level (no `--dataset`) | @@ -702,6 +703,7 @@ The identifier field location varies by asset kind: | `PrometheusRule` (alerting rules) | `metadata.labels["dash0.com/id"]` | The CRD-level label is applied to every alerting rule converted from the CRD, so a CRD with multiple alerts shares one identifier — pin a unique label per CRD, or split multi-alert CRDs into one CRD per alert | | `PrometheusRule` (recording rules) | `metadata.labels["dash0.com/id"]` | | | `SyntheticCheck` | `metadata.labels["dash0.com/id"]` | | +| `SLO` | `metadata.labels["dash0.com/id"]` | OpenSLO v1 document (`apiVersion: openslo/v1`) | | `View` | `metadata.labels["dash0.com/id"]` | | | `Dash0SpamFilter` (v1alpha1 and v1alpha2) | `metadata.labels["dash0.com/id"]` | `metadata.labels["dash0.com/origin"]` is preferred over the ID when both are present; an ID-only filter is not fully idempotent because the server reassigns the ID on the first PUT | | `Dash0NotificationChannel` | `metadata.labels["dash0.com/origin"]` | There is no user-settable ID field for notification channels — the origin label is the upsert key. A document without it creates a new channel on every apply | @@ -734,7 +736,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`, `Dash0SpamFilter`, `Dash0NotificationChannel`. +Supported `kind` values: `Dashboard`, `PersesDashboard`, `CheckRule`, `PrometheusRule`, `SyntheticCheck`, `SLO`, `View`, `Dash0SpamFilter`, `Dash0NotificationChannel`. A single file may contain multiple documents separated by `---`. For `Dash0SpamFilter`, the `apiVersion` field on the document selects the schema (`v1alpha1` or `v1alpha2`); a missing value defaults to `v1alpha1`. @@ -857,6 +859,45 @@ spec: interval: 60s ``` +SLO (OpenSLO v1 format): + +```yaml +apiVersion: openslo/v1 +kind: SLO +metadata: + name: checkout-availability + labels: + dash0.com/id: f6a7b8c9-0123-45f0-1234-67890abcdef0 + annotations: + dash0.com/display-name: Checkout availability +spec: + description: 99 percent of checkout HTTP requests succeed over a rolling 28-day window. + service: checkout + budgetingMethod: Occurrences + timeWindow: + - duration: 28d + isRolling: true + indicator: + metadata: + name: checkout-success-ratio + spec: + ratioMetric: + counter: true + good: + metricSource: + type: Prometheus + spec: + query: 'http_server_request_duration_seconds_count{service_name="checkout",http_response_status_code!~"5.."}' + total: + metricSource: + type: Prometheus + spec: + query: 'http_server_request_duration_seconds_count{service_name="checkout"}' + objectives: + - displayName: 99% availability + target: 0.99 +``` + Recording rule (PrometheusRule CRD format): ```yaml diff --git a/go.mod b/go.mod index 0b00b5d..9baecfe 100644 --- a/go.mod +++ b/go.mod @@ -100,3 +100,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/protobuf v1.36.11 // indirect ) + +replace github.com/dash0hq/dash0-api-client-go => ../../../dash0-api-client-go/.iac_maintainer/slos diff --git a/go.sum b/go.sum index c56d060..897d172 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,6 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/dash0hq/dash0-api-client-go v1.16.0 h1:xWY81K3QWH2ancLCYMPFUoCZc6HWQ5UnMRdqHbrgPRk= -github.com/dash0hq/dash0-api-client-go v1.16.0/go.mod h1:KNGbcgETrEN4m2QPGAorZ9FybTpVg/oi20UEoWGCGlE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/apply/apply.go b/internal/apply/apply.go index d9bc503..2ec292b 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -47,6 +47,7 @@ Supported asset types: - CheckRule (or PrometheusRule CRD with alerting rules) - PrometheusRule CRD with recording rules - SyntheticCheck + - SLO - View - Dash0SpamFilter - Dash0NotificationChannel @@ -172,7 +173,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, Dash0SpamFilter, Dash0NotificationChannel)", doc.location(), doc.kind)) + validationErrors = append(validationErrors, fmt.Sprintf("%s: unsupported kind %q (supported: Dashboard, PersesDashboard, CheckRule, PrometheusRule, SyntheticCheck, SLO, View, Dash0SpamFilter, Dash0NotificationChannel)", doc.location(), doc.kind)) } else if normalizeKind(doc.kind) == "spamfilter" { // Catch unknown spam filter apiVersions during validation rather // than after the first PUT, so a partial apply of a multi-doc input @@ -347,6 +348,14 @@ func parseDocumentHeader(data []byte) (kind, name, id string, err error) { name = dash0api.GetSyntheticCheckName(&check) id = dash0api.GetSyntheticCheckID(&check) + case "slo": + var slo dash0api.SloDefinition + if err := sigsyaml.Unmarshal(data, &slo); err != nil { + return "", "", "", fmt.Errorf("failed to decode document: %w", err) + } + name = dash0api.GetSLOName(&slo) + id = dash0api.GetSLOID(&slo) + 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. @@ -582,7 +591,7 @@ func readDirectory(dirPath string) ([]assetDocument, error) { func isValidKind(kind string) bool { switch normalizeKind(kind) { - case "dashboard", "checkrule", "syntheticcheck", "view", "prometheusrule", "persesdashboard", "spamfilter", "notificationchannel": + case "dashboard", "checkrule", "syntheticcheck", "slo", "view", "prometheusrule", "persesdashboard", "spamfilter", "notificationchannel": return true default: return false @@ -633,6 +642,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 "slo": + var slo dash0api.SloDefinition + if err := sigsyaml.Unmarshal(doc.raw, &slo); err != nil { + return nil, fmt.Errorf("failed to parse SLO: %w", err) + } + result, err := asset.ImportSLO(ctx, apiClient, &slo, dataset) + if err != nil { + return nil, client.HandleAPIError(err, client.ErrorContext{ + AssetType: "SLO", + AssetName: dash0api.GetSLOName(&slo), + }) + } + 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 if err := sigsyaml.Unmarshal(doc.raw, &view); err != nil { diff --git a/internal/asset/kind.go b/internal/asset/kind.go index 21fd2cc..4e21fd9 100644 --- a/internal/asset/kind.go +++ b/internal/asset/kind.go @@ -19,6 +19,8 @@ func KindDisplayName(kind string) string { return "Check rule" case "syntheticcheck": return "Synthetic check" + case "slo": + return "SLO" case "view": return "View" case "prometheusrule": diff --git a/internal/asset/slo.go b/internal/asset/slo.go new file mode 100644 index 0000000..8ceab67 --- /dev/null +++ b/internal/asset/slo.go @@ -0,0 +1,43 @@ +package asset + +import ( + "context" + + dash0api "github.com/dash0hq/dash0-api-client-go" +) + +// ImportSLO creates or updates an SLO via the standard CRUD APIs. +// When the input has a user-defined ID, UPDATE is always used — PUT has +// create-or-replace semantics, so this is idempotent regardless of whether the +// SLO already exists. +// When the input has no ID, CREATE is used and the server assigns an ID. +func ImportSLO(ctx context.Context, apiClient dash0api.Client, slo *dash0api.SloDefinition, dataset *string) (ImportResult, error) { + dash0api.StripSLOServerFields(slo) + + action := ActionCreated + var before any + id := dash0api.GetSLOID(slo) + if id != "" { + existing, err := apiClient.GetSLO(ctx, id, dataset) + if err == nil { + action = ActionUpdated + before = existing + } + } + + var result *dash0api.SloDefinition + var err error + if id != "" { + result, err = apiClient.UpdateSLO(ctx, id, slo, dataset) + } else { + result, err = apiClient.CreateSLO(ctx, slo, dataset) + } + if err != nil { + return ImportResult{}, err + } + + if resultID := dash0api.GetSLOID(result); resultID != "" { + id = resultID + } + return ImportResult{Name: dash0api.GetSLOName(result), ID: id, Action: action, Before: before, After: result}, nil +} diff --git a/internal/slos/create.go b/internal/slos/create.go new file mode 100644 index 0000000..7dc864c --- /dev/null +++ b/internal/slos/create.go @@ -0,0 +1,66 @@ +package slos + +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 newCreateCmd() *cobra.Command { + var flags asset.FileInputFlags + + cmd := &cobra.Command{ + Use: "create -f ", + Aliases: []string{"add"}, + Short: "Create an SLO from a file", + Long: `Create a new SLO from a YAML or JSON definition file in OpenSLO v1 format. Use '-f -' to read from stdin.` + internal.CONFIG_HINT, + Example: ` # Create from a YAML file + dash0 slos create -f slo.yaml + + # Create from stdin + cat slo.yaml | dash0 slos create -f - + + # Validate without creating + dash0 slos create -f slo.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 slo dash0api.SloDefinition + if err := asset.ReadDefinition(flags.File, &slo, os.Stdin); err != nil { + return fmt.Errorf("failed to read SLO definition: %w", err) + } + + if flags.DryRun { + fmt.Println("Dry run: SLO definition is valid") + return nil + } + + apiClient, err := client.NewClientFromContext(ctx, flags.ApiUrl, flags.AuthToken) + if err != nil { + return err + } + + result, importErr := asset.ImportSLO(ctx, apiClient, &slo, client.ResolveDataset(ctx, flags.Dataset)) + if importErr != nil { + return client.HandleAPIError(importErr, client.ErrorContext{ + AssetType: "SLO", + AssetName: dash0api.GetSLOName(&slo), + }) + } + + fmt.Printf("SLO %q %s\n", result.Name, result.Action) + return nil +} diff --git a/internal/slos/delete.go b/internal/slos/delete.go new file mode 100644 index 0000000..b6789ba --- /dev/null +++ b/internal/slos/delete.go @@ -0,0 +1,66 @@ +package slos + +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 an SLO", + Long: `Delete an SLO by its ID. Use --force to skip the confirmation prompt.` + internal.CONFIG_HINT, + Example: ` # Delete with confirmation prompt + dash0 slos delete + + # Delete without confirmation (for scripts and automation) + dash0 slos 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( + ctx, + fmt.Sprintf("Are you sure you want to delete SLO %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.DeleteSLO(ctx, id, client.ResolveDataset(ctx, flags.Dataset)) + if err != nil { + return client.HandleAPIError(err, client.ErrorContext{ + AssetType: "SLO", + AssetID: id, + }) + } + + fmt.Printf("SLO %q deleted\n", id) + return nil +} diff --git a/internal/slos/get.go b/internal/slos/get.go new file mode 100644 index 0000000..c6ddac7 --- /dev/null +++ b/internal/slos/get.go @@ -0,0 +1,84 @@ +package slos + +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 newGetCmd() *cobra.Command { + var flags asset.GetFlags + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get an SLO by ID", + Long: `Retrieve an SLO definition by its ID.` + internal.CONFIG_HINT, + Example: ` # Show SLO summary + dash0 slos get + + # Export as YAML (suitable for re-applying) + dash0 slos get -o yaml > slo.yaml + + # Export as JSON + dash0 slos 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) + dataset := client.ResolveDataset(ctx, flags.Dataset) + apiClient, err := client.NewClientFromContext(ctx, flags.ApiUrl, flags.AuthToken) + if err != nil { + return err + } + + slo, err := apiClient.GetSLO(ctx, id, dataset) + if err != nil { + return client.HandleAPIError(err, client.ErrorContext{ + AssetType: "SLO", + AssetID: id, + }) + } + + dash0api.SetSLOIDIfAbsent(slo, 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(slo) + default: + fmt.Printf("Kind: %s\n", slo.Kind) + fmt.Printf("Name: %s\n", dash0api.GetSLOName(slo)) + fmt.Printf("Dataset: %s\n", dash0api.GetSLODataset(slo)) + if slo.Spec.Service != nil && *slo.Spec.Service != "" { + fmt.Printf("Service: %s\n", *slo.Spec.Service) + } + if slo.Spec.Description != nil && *slo.Spec.Description != "" { + fmt.Printf("Description: %s\n", *slo.Spec.Description) + } + if deeplinkURL := dash0api.DeeplinkURL(apiUrl, dash0api.DeeplinkAssetTypeSLO, id, dataset); deeplinkURL != "" { + fmt.Printf("URL: %s\n", deeplinkURL) + } + return nil + } +} diff --git a/internal/slos/integration_test.go b/internal/slos/integration_test.go new file mode 100644 index 0000000..ac99343 --- /dev/null +++ b/internal/slos/integration_test.go @@ -0,0 +1,430 @@ +//go:build integration + +package slos + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "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" +) + +const ( + apiPathSLOs = "/api/slos" + testAuthToken = "auth_test_token" + testSLOID = "00000000-0000-0000-0000-000000000001" + fixtureListSuccess = "slos/list_success.json" + fixtureListEmpty = "slos/list_empty.json" + fixtureGetSuccess = "slos/get_success.json" + fixtureCreateSuccess = "slos/create_success.json" + fixtureUpdateSuccess = "slos/update_success.json" + fixtureNotFound = "slos/error_not_found.json" + fixtureUnauthorized = "dashboards/error_unauthorized.json" +) + +var sloIDPattern = regexp.MustCompile(`^/api/slos/[^/]+$`) + +func TestListSLOs_JSONFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathSLOs, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureListSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewSlosCmd() + 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) + assert.Contains(t, output, `"kind": "SLO"`) + assert.Contains(t, output, `"apiVersion": "openslo/v1"`) + assert.Contains(t, output, `"metadata"`) + assert.Contains(t, output, `"spec"`) +} + +func TestListSLOs_YAMLFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathSLOs, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureListSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewSlosCmd() + 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) + assert.Contains(t, output, "kind: SLO") + assert.Contains(t, output, "apiVersion: openslo/v1") + assert.Contains(t, output, "metadata:") + assert.Contains(t, output, "spec:") +} + +func TestListSLOs_TableFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathSLOs, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureListSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewSlosCmd() + cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.NoError(t, err) + assert.Contains(t, output, "NAME") + assert.Contains(t, output, "ID") + assert.Contains(t, output, "Checkout availability") + assert.Contains(t, output, testSLOID) +} + +func TestListSLOs_CSVFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathSLOs, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureListSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewSlosCmd() + cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "csv"}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.NoError(t, err) + // CSV/wide output adds a URL deep-link column. + assert.Contains(t, output, "url") + assert.Contains(t, output, "slo_id="+testSLOID) + assert.Contains(t, output, "Checkout availability") +} + +func TestListSLOs_Empty(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathSLOs, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureListEmpty, + Validator: testutil.RequireHeaders, + }) + + cmd := NewSlosCmd() + cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.NoError(t, err) + assert.Contains(t, output, "No SLOs found.") +} + +func TestListSLOs_AuthError(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodGet, apiPathSLOs, testutil.MockResponse{ + StatusCode: http.StatusUnauthorized, + BodyFile: fixtureUnauthorized, + }) + + cmd := NewSlosCmd() + cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken}) + + var err error + testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.Error(t, err) +} + +func TestGetSLO_JSONFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, sloIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureGetSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewSlosCmd() + cmd.SetArgs([]string{"get", testSLOID, "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "json"}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.NoError(t, err) + assert.Contains(t, output, `"kind": "SLO"`) + assert.Contains(t, output, `"checkout"`) +} + +func TestGetSLO_YAMLFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, sloIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureGetSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewSlosCmd() + cmd.SetArgs([]string{"get", testSLOID, "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "yaml"}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.NoError(t, err) + assert.Contains(t, output, "kind: SLO") + assert.Contains(t, output, "apiVersion: openslo/v1") +} + +func TestGetSLO_DefaultFormat(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, sloIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureGetSuccess, + Validator: testutil.RequireHeaders, + }) + + cmd := NewSlosCmd() + cmd.SetArgs([]string{"get", testSLOID, "--api-url", server.URL, "--auth-token", testAuthToken}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.NoError(t, err) + assert.Contains(t, output, "Kind: SLO") + assert.Contains(t, output, "Name: Checkout availability") + assert.Contains(t, output, "Service: checkout") +} + +func TestGetSLO_NotFound(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, sloIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: fixtureNotFound, + }) + + cmd := NewSlosCmd() + cmd.SetArgs([]string{"get", testSLOID, "--api-url", server.URL, "--auth-token", testAuthToken}) + + var err error + testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.Error(t, err) +} + +func TestCreateSLO_DatasetQueryParam(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.On(http.MethodPost, apiPathSLOs, testutil.MockResponse{ + StatusCode: http.StatusCreated, + BodyFile: fixtureCreateSuccess, + Validator: testutil.RequireHeaders, + }) + + tmpDir := t.TempDir() + yamlFile := filepath.Join(tmpDir, "slo.yaml") + require.NoError(t, os.WriteFile(yamlFile, []byte(sloCreateYAML), 0644)) + + cmd := NewSlosCmd() + cmd.SetArgs([]string{"create", "-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken, "--dataset", "my-dataset"}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + require.NoError(t, err) + assert.Contains(t, output, `SLO "Checkout availability" created`) + + req := server.LastRequest() + require.NotNil(t, req) + assert.Equal(t, http.MethodPost, req.Method) + assert.Contains(t, req.Query, "dataset=my-dataset") + assert.True(t, strings.HasPrefix(req.Header.Get("Authorization"), "Bearer ")) +} + +func TestUpdateSLO_DatasetQueryParam(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, sloIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureGetSuccess, + Validator: testutil.RequireHeaders, + }) + server.OnPattern(http.MethodPut, sloIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixtureUpdateSuccess, + Validator: testutil.RequireHeaders, + }) + + tmpDir := t.TempDir() + yamlFile := filepath.Join(tmpDir, "slo.yaml") + require.NoError(t, os.WriteFile(yamlFile, []byte(sloUpdateYAML), 0644)) + + cmd := NewSlosCmd() + cmd.SetArgs([]string{"update", "-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken, "--dataset", "my-dataset"}) + + var err error + testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + require.NoError(t, err) + + req := server.LastRequest() + require.NotNil(t, req) + assert.Equal(t, http.MethodPut, req.Method) + assert.Contains(t, req.Query, "dataset=my-dataset") + + // The outbound body must be a valid OpenSLO v1 document. + var sent dash0api.SloDefinition + require.NoError(t, json.Unmarshal(req.Body, &sent)) + assert.Equal(t, dash0api.Openslov1, sent.ApiVersion) + assert.Equal(t, dash0api.SLO, sent.Kind) +} + +func TestDeleteSLO_Success(t *testing.T) { + testutil.SetupTestEnv(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodDelete, sloIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + + cmd := NewSlosCmd() + cmd.SetArgs([]string{"delete", testSLOID, "--api-url", server.URL, "--auth-token", testAuthToken, "--force"}) + + var err error + output := testutil.CaptureStdout(t, func() { + err = cmd.Execute() + }) + + require.NoError(t, err) + assert.Contains(t, output, "deleted") +} + +const sloCreateYAML = `apiVersion: openslo/v1 +kind: SLO +metadata: + name: checkout-availability + annotations: + dash0.com/display-name: Checkout availability + dash0.com/enabled: "true" +spec: + description: 99 percent of checkout HTTP requests succeed over a rolling 28-day window. + service: checkout + budgetingMethod: Occurrences + timeWindow: + - duration: 28d + isRolling: true + indicator: + metadata: + name: checkout-success-ratio + spec: + ratioMetric: + counter: true + good: + metricSource: + type: Prometheus + spec: + query: 'http_server_request_duration_seconds_count{service_name="checkout",http_response_status_code!~"5.."}' + total: + metricSource: + type: Prometheus + spec: + query: 'http_server_request_duration_seconds_count{service_name="checkout"}' + objectives: + - displayName: 99% availability + target: 0.99 +` + +const sloUpdateYAML = `apiVersion: openslo/v1 +kind: SLO +metadata: + name: checkout-availability + labels: + dash0.com/id: 00000000-0000-0000-0000-000000000001 + annotations: + dash0.com/display-name: Checkout availability + dash0.com/enabled: "true" +spec: + description: 99.5 percent of checkout HTTP requests succeed over a rolling 28-day window. + service: checkout + budgetingMethod: Occurrences + timeWindow: + - duration: 28d + isRolling: true + indicator: + metadata: + name: checkout-success-ratio + spec: + ratioMetric: + counter: true + good: + metricSource: + type: Prometheus + spec: + query: 'http_server_request_duration_seconds_count{service_name="checkout",http_response_status_code!~"5.."}' + total: + metricSource: + type: Prometheus + spec: + query: 'http_server_request_duration_seconds_count{service_name="checkout"}' + objectives: + - displayName: 99.5% availability + target: 0.995 +` diff --git a/internal/slos/list.go b/internal/slos/list.go new file mode 100644 index 0000000..c7f1082 --- /dev/null +++ b/internal/slos/list.go @@ -0,0 +1,134 @@ +package slos + +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 SLOs", + Long: `List all SLOs in the specified dataset.` + internal.CONFIG_HINT, + Example: ` # List SLOs (default: up to 50) + dash0 slos list + + # Output as YAML for backup or version control + dash0 slos list -o yaml > slos.yaml + + # Output as JSON for scripting + dash0 slos list -o json + + # Output as CSV (pipe-friendly) + dash0 slos list -o csv + + # List without the header row (pipe-friendly) + dash0 slos 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 + } + + apiUrl := client.ResolveApiUrl(ctx, flags.ApiUrl) + apiClient, err := client.NewClientFromContext(ctx, flags.ApiUrl, flags.AuthToken) + if err != nil { + return err + } + + dataset := client.ResolveDataset(ctx, flags.Dataset) + iter := apiClient.ListSLOsIter(ctx, dataset) + + var items []*dash0api.SloDefinition + count := 0 + for iter.Next() { + items = append(items, 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: "SLO", + }) + } + + 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{}, 0, len(items)) + for _, slo := range items { + definitions = append(definitions, slo) + } + if format == output.FormatYAML { + return formatter.PrintMultiDocYAML(definitions) + } + return formatter.PrintJSON(definitions) + default: + return printSLOTable(formatter, items, format, apiUrl, dataset) + } +} + +func printSLOTable(f *output.Formatter, items []*dash0api.SloDefinition, format output.Format, apiUrl string, dataset *string) error { + columns := []output.Column{ + {Header: internal.HEADER_NAME, Width: 40, Value: func(item interface{}) string { + return dash0api.GetSLOName(item.(*dash0api.SloDefinition)) + }}, + {Header: internal.HEADER_ID, Width: 36, Value: func(item interface{}) string { + return dash0api.GetSLOID(item.(*dash0api.SloDefinition)) + }}, + } + + if format == output.FormatWide || format == output.FormatCSV { + columns = append(columns, + output.Column{Header: internal.HEADER_DATASET, Width: 15, Value: func(item interface{}) string { + return dash0api.GetSLODataset(item.(*dash0api.SloDefinition)) + }}, + output.Column{Header: internal.HEADER_URL, Width: 70, Value: func(item interface{}) string { + return dash0api.DeeplinkURL(apiUrl, dash0api.DeeplinkAssetTypeSLO, dash0api.GetSLOID(item.(*dash0api.SloDefinition)), dataset) + }}, + ) + } + + if len(items) == 0 { + fmt.Println("No SLOs found.") + return nil + } + + data := make([]interface{}, len(items)) + for i, s := range items { + data[i] = s + } + + if format == output.FormatCSV { + return f.PrintCSV(columns, data) + } + return f.PrintTable(columns, data) +} diff --git a/internal/slos/slos_cmd.go b/internal/slos/slos_cmd.go new file mode 100644 index 0000000..3de0da8 --- /dev/null +++ b/internal/slos/slos_cmd.go @@ -0,0 +1,20 @@ +package slos + +import "github.com/spf13/cobra" + +// NewSlosCmd creates the slos parent command +func NewSlosCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "slos", + Short: "Manage Dash0 service level objectives (SLOs)", + Long: `Create, list, update, and delete service level objectives (SLOs) in Dash0. SLO documents use the OpenSLO v1 format (apiVersion: openslo/v1, kind: SLO).`, + } + + cmd.AddCommand(newListCmd()) + cmd.AddCommand(newGetCmd()) + cmd.AddCommand(newCreateCmd()) + cmd.AddCommand(newUpdateCmd()) + cmd.AddCommand(newDeleteCmd()) + + return cmd +} diff --git a/internal/slos/update.go b/internal/slos/update.go new file mode 100644 index 0000000..cd31f24 --- /dev/null +++ b/internal/slos/update.go @@ -0,0 +1,93 @@ +package slos + +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 an SLO from a file", + Long: `Update an existing SLO from a YAML or JSON definition file in OpenSLO v1 format. 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 an SLO from a file + dash0 slos update -f slo.yaml + + # Update using the ID from the file + dash0 slos update -f slo.yaml + + # Export, edit, and update + dash0 slos get -o yaml > slo.yaml + # edit slo.yaml + dash0 slos update -f slo.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 slo dash0api.SloDefinition + if err := asset.ReadDefinition(flags.File, &slo, os.Stdin); err != nil { + return fmt.Errorf("failed to read SLO definition: %w", err) + } + + var id string + fileID := dash0api.GetSLOID(&slo) + 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 SLO 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.GetSLO(ctx, id, dataset) + if err != nil { + return client.HandleAPIError(err, client.ErrorContext{ + AssetType: "SLO", + AssetID: id, + }) + } + + if flags.DryRun { + return asset.PrintDiff(os.Stdout, "SLO", slo.Metadata.Name, before, &slo) + } + + result, err := apiClient.UpdateSLO(ctx, id, &slo, dataset) + if err != nil { + return client.HandleAPIError(err, client.ErrorContext{ + AssetType: "SLO", + AssetID: id, + AssetName: dash0api.GetSLOName(&slo), + }) + } + + return asset.PrintDiff(os.Stdout, "SLO", dash0api.GetSLOName(result), before, result) +} diff --git a/internal/testutil/fixtures/slos/create_success.json b/internal/testutil/fixtures/slos/create_success.json new file mode 100644 index 0000000..63df6ad --- /dev/null +++ b/internal/testutil/fixtures/slos/create_success.json @@ -0,0 +1,61 @@ +{ + "apiVersion": "openslo/v1", + "kind": "SLO", + "metadata": { + "name": "checkout-availability", + "labels": { + "dash0.com/id": "00000000-0000-0000-0000-000000000001", + "dash0.com/version": "1", + "dash0.com/dataset": "default" + }, + "annotations": { + "dash0.com/display-name": "Checkout availability", + "dash0.com/enabled": "true", + "dash0.com/created-at": "2026-01-15T10:00:00Z", + "dash0.com/updated-at": "2026-01-15T10:00:00Z" + } + }, + "spec": { + "description": "99 percent of checkout HTTP requests succeed over a rolling 28-day window.", + "service": "checkout", + "budgetingMethod": "Occurrences", + "timeWindow": [ + { + "duration": "28d", + "isRolling": true + } + ], + "indicator": { + "metadata": { + "name": "checkout-success-ratio" + }, + "spec": { + "ratioMetric": { + "counter": true, + "good": { + "metricSource": { + "type": "Prometheus", + "spec": { + "query": "http_server_request_duration_seconds_count{service_name=\"checkout\",http_response_status_code!~\"5..\"}" + } + } + }, + "total": { + "metricSource": { + "type": "Prometheus", + "spec": { + "query": "http_server_request_duration_seconds_count{service_name=\"checkout\"}" + } + } + } + } + } + }, + "objectives": [ + { + "displayName": "99% availability", + "target": 0.99 + } + ] + } +} diff --git a/internal/testutil/fixtures/slos/error_not_found.json b/internal/testutil/fixtures/slos/error_not_found.json new file mode 100644 index 0000000..c7bea47 --- /dev/null +++ b/internal/testutil/fixtures/slos/error_not_found.json @@ -0,0 +1,7 @@ +{ + "error": { + "code": 404, + "message": "Not Found: The requested SLO does not exist or is inaccessible to you.", + "traceId": "4ec8f8e3ebd6c62b2e41bd8450534c28" + } +} diff --git a/internal/testutil/fixtures/slos/get_success.json b/internal/testutil/fixtures/slos/get_success.json new file mode 100644 index 0000000..63df6ad --- /dev/null +++ b/internal/testutil/fixtures/slos/get_success.json @@ -0,0 +1,61 @@ +{ + "apiVersion": "openslo/v1", + "kind": "SLO", + "metadata": { + "name": "checkout-availability", + "labels": { + "dash0.com/id": "00000000-0000-0000-0000-000000000001", + "dash0.com/version": "1", + "dash0.com/dataset": "default" + }, + "annotations": { + "dash0.com/display-name": "Checkout availability", + "dash0.com/enabled": "true", + "dash0.com/created-at": "2026-01-15T10:00:00Z", + "dash0.com/updated-at": "2026-01-15T10:00:00Z" + } + }, + "spec": { + "description": "99 percent of checkout HTTP requests succeed over a rolling 28-day window.", + "service": "checkout", + "budgetingMethod": "Occurrences", + "timeWindow": [ + { + "duration": "28d", + "isRolling": true + } + ], + "indicator": { + "metadata": { + "name": "checkout-success-ratio" + }, + "spec": { + "ratioMetric": { + "counter": true, + "good": { + "metricSource": { + "type": "Prometheus", + "spec": { + "query": "http_server_request_duration_seconds_count{service_name=\"checkout\",http_response_status_code!~\"5..\"}" + } + } + }, + "total": { + "metricSource": { + "type": "Prometheus", + "spec": { + "query": "http_server_request_duration_seconds_count{service_name=\"checkout\"}" + } + } + } + } + } + }, + "objectives": [ + { + "displayName": "99% availability", + "target": 0.99 + } + ] + } +} diff --git a/internal/testutil/fixtures/slos/list_empty.json b/internal/testutil/fixtures/slos/list_empty.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/internal/testutil/fixtures/slos/list_empty.json @@ -0,0 +1 @@ +[] diff --git a/internal/testutil/fixtures/slos/list_success.json b/internal/testutil/fixtures/slos/list_success.json new file mode 100644 index 0000000..a22fee5 --- /dev/null +++ b/internal/testutil/fixtures/slos/list_success.json @@ -0,0 +1,63 @@ +[ +{ + "apiVersion": "openslo/v1", + "kind": "SLO", + "metadata": { + "name": "checkout-availability", + "labels": { + "dash0.com/id": "00000000-0000-0000-0000-000000000001", + "dash0.com/version": "1", + "dash0.com/dataset": "default" + }, + "annotations": { + "dash0.com/display-name": "Checkout availability", + "dash0.com/enabled": "true", + "dash0.com/created-at": "2026-01-15T10:00:00Z", + "dash0.com/updated-at": "2026-01-15T10:00:00Z" + } + }, + "spec": { + "description": "99 percent of checkout HTTP requests succeed over a rolling 28-day window.", + "service": "checkout", + "budgetingMethod": "Occurrences", + "timeWindow": [ + { + "duration": "28d", + "isRolling": true + } + ], + "indicator": { + "metadata": { + "name": "checkout-success-ratio" + }, + "spec": { + "ratioMetric": { + "counter": true, + "good": { + "metricSource": { + "type": "Prometheus", + "spec": { + "query": "http_server_request_duration_seconds_count{service_name=\"checkout\",http_response_status_code!~\"5..\"}" + } + } + }, + "total": { + "metricSource": { + "type": "Prometheus", + "spec": { + "query": "http_server_request_duration_seconds_count{service_name=\"checkout\"}" + } + } + } + } + } + }, + "objectives": [ + { + "displayName": "99% availability", + "target": 0.99 + } + ] + } +} +] diff --git a/internal/testutil/fixtures/slos/update_success.json b/internal/testutil/fixtures/slos/update_success.json new file mode 100644 index 0000000..be02e04 --- /dev/null +++ b/internal/testutil/fixtures/slos/update_success.json @@ -0,0 +1,61 @@ +{ + "apiVersion": "openslo/v1", + "kind": "SLO", + "metadata": { + "name": "checkout-availability", + "labels": { + "dash0.com/id": "00000000-0000-0000-0000-000000000001", + "dash0.com/version": "2", + "dash0.com/dataset": "default" + }, + "annotations": { + "dash0.com/display-name": "Checkout availability", + "dash0.com/enabled": "true", + "dash0.com/created-at": "2026-01-15T10:00:00Z", + "dash0.com/updated-at": "2026-01-15T12:00:00Z" + } + }, + "spec": { + "description": "99.5 percent of checkout HTTP requests succeed over a rolling 28-day window.", + "service": "checkout", + "budgetingMethod": "Occurrences", + "timeWindow": [ + { + "duration": "28d", + "isRolling": true + } + ], + "indicator": { + "metadata": { + "name": "checkout-success-ratio" + }, + "spec": { + "ratioMetric": { + "counter": true, + "good": { + "metricSource": { + "type": "Prometheus", + "spec": { + "query": "http_server_request_duration_seconds_count{service_name=\"checkout\",http_response_status_code!~\"5..\"}" + } + } + }, + "total": { + "metricSource": { + "type": "Prometheus", + "spec": { + "query": "http_server_request_duration_seconds_count{service_name=\"checkout\"}" + } + } + } + } + } + }, + "objectives": [ + { + "displayName": "99.5% availability", + "target": 0.995 + } + ] + } +} diff --git a/test/roundtrip/fixtures/slo.yaml b/test/roundtrip/fixtures/slo.yaml new file mode 100644 index 0000000..ad905c5 --- /dev/null +++ b/test/roundtrip/fixtures/slo.yaml @@ -0,0 +1,33 @@ +apiVersion: openslo/v1 +kind: SLO +metadata: + name: checkout-availability + annotations: + dash0.com/display-name: Checkout availability + dash0.com/enabled: "true" +spec: + description: 99 percent of checkout HTTP requests succeed over a rolling 28-day window. + service: checkout + budgetingMethod: Occurrences + timeWindow: + - duration: 28d + isRolling: true + indicator: + metadata: + name: checkout-success-ratio + spec: + ratioMetric: + counter: true + good: + metricSource: + type: Prometheus + spec: + query: 'http_server_request_duration_seconds_count{service_name="checkout",http_response_status_code!~"5.."}' + total: + metricSource: + type: Prometheus + spec: + query: 'http_server_request_duration_seconds_count{service_name="checkout"}' + objectives: + - displayName: 99% availability + target: 0.99 diff --git a/test/roundtrip/run_all.sh b/test/roundtrip/run_all.sh index e0a6ff1..3d440fc 100755 --- a/test/roundtrip/run_all.sh +++ b/test/roundtrip/run_all.sh @@ -27,6 +27,7 @@ API_TESTS=( "${SCRIPT_DIR}/test_dashboard_roundtrip.sh" "${SCRIPT_DIR}/test_check_rule_roundtrip.sh" "${SCRIPT_DIR}/test_synthetic_check_roundtrip.sh" + "${SCRIPT_DIR}/test_slo_roundtrip.sh" "${SCRIPT_DIR}/test_view_roundtrip.sh" "${SCRIPT_DIR}/test_apply_dashboard_idempotency.sh" "${SCRIPT_DIR}/test_apply_check_rule_idempotency.sh" diff --git a/test/roundtrip/test_slo_roundtrip.sh b/test/roundtrip/test_slo_roundtrip.sh new file mode 100755 index 0000000..c01e456 --- /dev/null +++ b/test/roundtrip/test_slo_roundtrip.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DASH0_AGENT_MODE=0 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DASH0="${SCRIPT_DIR}/../../build/dash0" +FIXTURES="${SCRIPT_DIR}/fixtures" +FIXTURE="${FIXTURES}/slo.yaml" +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +ASSET_NAME=$(yq '.metadata.annotations."dash0.com/display-name"' "$FIXTURE") + +echo "=== SLO round-trip test ===" +echo "Asset name: $ASSET_NAME" + +# Step 1: Create from fixture +echo "--- Step 1: Create SLO from fixture ---" +if ! CREATE_OUTPUT=$("$DASH0" slos create -f "$FIXTURE"); then + echo "FAIL: slos 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 SLOs and find the created asset by name +echo "--- Step 2: List SLOs and find created asset ---" +if ! LIST_JSON=$("$DASH0" slos list --all -o json); then + echo "FAIL: slos list -o json failed" + exit 1 +fi +ID=$(echo "$LIST_JSON" | jq -r --arg name "$ASSET_NAME" '[.[] | select(.metadata.annotations["dash0.com/display-name"] == $name)][0].metadata.labels["dash0.com/id"] // empty') +if [ -z "$ID" ]; then + echo "FAIL: Could not find created SLO '$ASSET_NAME' in list" + exit 1 +fi +echo "Created SLO ID: $ID" + +# Step 3: Get by ID +echo "--- Step 3: Get SLO by ID ---" +if ! "$DASH0" slos get "$ID"; then + echo "FAIL: slos get failed" + exit 1 +fi + +# Step 4: Export to YAML +echo "--- Step 4: Export SLO to YAML ---" +if ! "$DASH0" slos get "$ID" -o yaml > "${TMPDIR}/exported.yaml"; then + echo "FAIL: slos get -o yaml failed" + exit 1 +fi +echo "Exported to ${TMPDIR}/exported.yaml" + +# Step 5: Re-import exported YAML 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 SLO ---" +if ! "$DASH0" slos delete "$ID" --force; then + echo "FAIL: slos delete failed" + exit 1 +fi + +# Step 7: Verify deletion +echo "--- Step 7: Verify deletion ---" +if ! LIST_JSON=$("$DASH0" slos list --all -o json); then + echo "FAIL: slos 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: SLO '$ID' still exists after deletion" + exit 1 +fi + +echo "=== SLO round-trip test PASSED ===" From e152841ea0100bbe769384acccd92c83aca3cdec Mon Sep 17 00:00:00 2001 From: Nicolas Takashi Date: Thu, 23 Jul 2026 16:57:15 +0100 Subject: [PATCH 2/2] chore: align SLO apiVersion to openslo.com/v1 The Dash0 SLO API document version changed from openslo/v1 to openslo.com/v1. Update the CLI's SLO fixtures, tests, help text, docs, and changelog entry to use the new apiVersion token. Co-Authored-By: Claude Opus 4.8 (1M context) --- .chloggen/slos.yaml | 2 +- docs/commands.md | 6 +++--- internal/slos/integration_test.go | 12 ++++++------ internal/slos/slos_cmd.go | 2 +- internal/testutil/fixtures/slos/create_success.json | 2 +- internal/testutil/fixtures/slos/get_success.json | 2 +- internal/testutil/fixtures/slos/list_success.json | 2 +- internal/testutil/fixtures/slos/update_success.json | 2 +- test/roundtrip/fixtures/slo.yaml | 2 +- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.chloggen/slos.yaml b/.chloggen/slos.yaml index 1d544d1..c07e7c2 100644 --- a/.chloggen/slos.yaml +++ b/.chloggen/slos.yaml @@ -16,7 +16,7 @@ issues: [206] # These lines will be padded with 2 spaces and then inserted directly into the document. # Use pipe (|) for multiline entries. subtext: | - SLO documents use the OpenSLO v1 format (`apiVersion: openslo/v1`, `kind: SLO`) and are dataset-scoped via `--dataset`. + SLO documents use the OpenSLO v1 format (`apiVersion: openslo.com/v1`, `kind: SLO`) and are dataset-scoped via `--dataset`. The commands support `create`, `list`, `get`, `update`, and `delete`, and SLOs can also be managed through `dash0 apply`. # If your change doesn't affect end users or the exported elements of any package, diff --git a/docs/commands.md b/docs/commands.md index 2091b41..e04bf21 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -679,7 +679,7 @@ Aliases: `remove` | 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 ` | | -| SLOs | `dash0 slos ` | Dataset-scoped; documents use the OpenSLO v1 format (`apiVersion: openslo/v1`, `kind: SLO`) | +| SLOs | `dash0 slos ` | Dataset-scoped; documents use the OpenSLO v1 format (`apiVersion: openslo.com/v1`, `kind: SLO`) | | Views | `dash0 views ` | | | Recording rules | `dash0 recording-rules ` | Uses PrometheusRule CRD format | | Notification channels | `dash0 notification-channels ` | Organization-level (no `--dataset`) | @@ -703,7 +703,7 @@ The identifier field location varies by asset kind: | `PrometheusRule` (alerting rules) | `metadata.labels["dash0.com/id"]` | The CRD-level label is applied to every alerting rule converted from the CRD, so a CRD with multiple alerts shares one identifier — pin a unique label per CRD, or split multi-alert CRDs into one CRD per alert | | `PrometheusRule` (recording rules) | `metadata.labels["dash0.com/id"]` | | | `SyntheticCheck` | `metadata.labels["dash0.com/id"]` | | -| `SLO` | `metadata.labels["dash0.com/id"]` | OpenSLO v1 document (`apiVersion: openslo/v1`) | +| `SLO` | `metadata.labels["dash0.com/id"]` | OpenSLO v1 document (`apiVersion: openslo.com/v1`) | | `View` | `metadata.labels["dash0.com/id"]` | | | `Dash0SpamFilter` (v1alpha1 and v1alpha2) | `metadata.labels["dash0.com/id"]` | `metadata.labels["dash0.com/origin"]` is preferred over the ID when both are present; an ID-only filter is not fully idempotent because the server reassigns the ID on the first PUT | | `Dash0NotificationChannel` | `metadata.labels["dash0.com/origin"]` | There is no user-settable ID field for notification channels — the origin label is the upsert key. A document without it creates a new channel on every apply | @@ -862,7 +862,7 @@ spec: SLO (OpenSLO v1 format): ```yaml -apiVersion: openslo/v1 +apiVersion: openslo.com/v1 kind: SLO metadata: name: checkout-availability diff --git a/internal/slos/integration_test.go b/internal/slos/integration_test.go index ac99343..12d1b58 100644 --- a/internal/slos/integration_test.go +++ b/internal/slos/integration_test.go @@ -52,7 +52,7 @@ func TestListSLOs_JSONFormat(t *testing.T) { require.NoError(t, err) assert.Contains(t, output, `"kind": "SLO"`) - assert.Contains(t, output, `"apiVersion": "openslo/v1"`) + assert.Contains(t, output, `"apiVersion": "openslo.com/v1"`) assert.Contains(t, output, `"metadata"`) assert.Contains(t, output, `"spec"`) } @@ -77,7 +77,7 @@ func TestListSLOs_YAMLFormat(t *testing.T) { require.NoError(t, err) assert.Contains(t, output, "kind: SLO") - assert.Contains(t, output, "apiVersion: openslo/v1") + assert.Contains(t, output, "apiVersion: openslo.com/v1") assert.Contains(t, output, "metadata:") assert.Contains(t, output, "spec:") } @@ -217,7 +217,7 @@ func TestGetSLO_YAMLFormat(t *testing.T) { require.NoError(t, err) assert.Contains(t, output, "kind: SLO") - assert.Contains(t, output, "apiVersion: openslo/v1") + assert.Contains(t, output, "apiVersion: openslo.com/v1") } func TestGetSLO_DefaultFormat(t *testing.T) { @@ -331,7 +331,7 @@ func TestUpdateSLO_DatasetQueryParam(t *testing.T) { // The outbound body must be a valid OpenSLO v1 document. var sent dash0api.SloDefinition require.NoError(t, json.Unmarshal(req.Body, &sent)) - assert.Equal(t, dash0api.Openslov1, sent.ApiVersion) + assert.Equal(t, "openslo.com/v1", string(sent.ApiVersion)) assert.Equal(t, dash0api.SLO, sent.Kind) } @@ -357,7 +357,7 @@ func TestDeleteSLO_Success(t *testing.T) { assert.Contains(t, output, "deleted") } -const sloCreateYAML = `apiVersion: openslo/v1 +const sloCreateYAML = `apiVersion: openslo.com/v1 kind: SLO metadata: name: checkout-availability @@ -392,7 +392,7 @@ spec: target: 0.99 ` -const sloUpdateYAML = `apiVersion: openslo/v1 +const sloUpdateYAML = `apiVersion: openslo.com/v1 kind: SLO metadata: name: checkout-availability diff --git a/internal/slos/slos_cmd.go b/internal/slos/slos_cmd.go index 3de0da8..f73db94 100644 --- a/internal/slos/slos_cmd.go +++ b/internal/slos/slos_cmd.go @@ -7,7 +7,7 @@ func NewSlosCmd() *cobra.Command { cmd := &cobra.Command{ Use: "slos", Short: "Manage Dash0 service level objectives (SLOs)", - Long: `Create, list, update, and delete service level objectives (SLOs) in Dash0. SLO documents use the OpenSLO v1 format (apiVersion: openslo/v1, kind: SLO).`, + Long: `Create, list, update, and delete service level objectives (SLOs) in Dash0. SLO documents use the OpenSLO v1 format (apiVersion: openslo.com/v1, kind: SLO).`, } cmd.AddCommand(newListCmd()) diff --git a/internal/testutil/fixtures/slos/create_success.json b/internal/testutil/fixtures/slos/create_success.json index 63df6ad..5858530 100644 --- a/internal/testutil/fixtures/slos/create_success.json +++ b/internal/testutil/fixtures/slos/create_success.json @@ -1,5 +1,5 @@ { - "apiVersion": "openslo/v1", + "apiVersion": "openslo.com/v1", "kind": "SLO", "metadata": { "name": "checkout-availability", diff --git a/internal/testutil/fixtures/slos/get_success.json b/internal/testutil/fixtures/slos/get_success.json index 63df6ad..5858530 100644 --- a/internal/testutil/fixtures/slos/get_success.json +++ b/internal/testutil/fixtures/slos/get_success.json @@ -1,5 +1,5 @@ { - "apiVersion": "openslo/v1", + "apiVersion": "openslo.com/v1", "kind": "SLO", "metadata": { "name": "checkout-availability", diff --git a/internal/testutil/fixtures/slos/list_success.json b/internal/testutil/fixtures/slos/list_success.json index a22fee5..ea06646 100644 --- a/internal/testutil/fixtures/slos/list_success.json +++ b/internal/testutil/fixtures/slos/list_success.json @@ -1,6 +1,6 @@ [ { - "apiVersion": "openslo/v1", + "apiVersion": "openslo.com/v1", "kind": "SLO", "metadata": { "name": "checkout-availability", diff --git a/internal/testutil/fixtures/slos/update_success.json b/internal/testutil/fixtures/slos/update_success.json index be02e04..5133efa 100644 --- a/internal/testutil/fixtures/slos/update_success.json +++ b/internal/testutil/fixtures/slos/update_success.json @@ -1,5 +1,5 @@ { - "apiVersion": "openslo/v1", + "apiVersion": "openslo.com/v1", "kind": "SLO", "metadata": { "name": "checkout-availability", diff --git a/test/roundtrip/fixtures/slo.yaml b/test/roundtrip/fixtures/slo.yaml index ad905c5..c7af446 100644 --- a/test/roundtrip/fixtures/slo.yaml +++ b/test/roundtrip/fixtures/slo.yaml @@ -1,4 +1,4 @@ -apiVersion: openslo/v1 +apiVersion: openslo.com/v1 kind: SLO metadata: name: checkout-availability