diff --git a/.chloggen/slos.yaml b/.chloggen/slos.yaml new file mode 100644 index 0000000..c07e7c2 --- /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.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, +# 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..e04bf21 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.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`) | @@ -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.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 | @@ -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.com/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..12d1b58 --- /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.com/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.com/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.com/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, "openslo.com/v1", string(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.com/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.com/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..f73db94 --- /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.com/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..5858530 --- /dev/null +++ b/internal/testutil/fixtures/slos/create_success.json @@ -0,0 +1,61 @@ +{ + "apiVersion": "openslo.com/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..5858530 --- /dev/null +++ b/internal/testutil/fixtures/slos/get_success.json @@ -0,0 +1,61 @@ +{ + "apiVersion": "openslo.com/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..ea06646 --- /dev/null +++ b/internal/testutil/fixtures/slos/list_success.json @@ -0,0 +1,63 @@ +[ +{ + "apiVersion": "openslo.com/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..5133efa --- /dev/null +++ b/internal/testutil/fixtures/slos/update_success.json @@ -0,0 +1,61 @@ +{ + "apiVersion": "openslo.com/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..c7af446 --- /dev/null +++ b/test/roundtrip/fixtures/slo.yaml @@ -0,0 +1,33 @@ +apiVersion: openslo.com/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 ==="