Skip to content

Commit f42afb3

Browse files
fix(slos): address PR #207 review feedback
- delete: add IsAlreadyDeleted idempotency branch so `slos delete --force` on an already-deleted SLO exits 0 (matches the 8 other delete commands, CHANGELOG 1.16.2 / #217); cover with an integration test. - update: StripSLOServerFields before the dry-run diff and PUT so `get -o yaml | update -f` no longer sends server-managed labels/timestamps or shows a spurious diff (mirrors views/update.go). - asset/ImportSLO: mirror ImportTeam upsert-key selection — prefer dash0.com/id, fall back to dash0.com/origin (read before strip), POST only when neither is present, so an origin-only document upserts via PUT instead of duplicating on every apply (#227 shape). - list: replace interface{} with any on the column closures (matches views/list.go). - integration tests: assert the update wire body strips dash0.com/version, dash0.com/origin, and created-at; add create/update --dry-run tests; add a second element to list_success.json and exercise --limit truncation. - roundtrip: add test_apply_slo_idempotency.sh (double-apply, no duplicate / stable id) and register it in run_all.sh. - docs: add teams to the command-group enumerations in commands.md and cli-naming-conventions.md and adjust the count wording.
1 parent ec238ac commit f42afb3

10 files changed

Lines changed: 403 additions & 25 deletions

File tree

docs/cli-naming-conventions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ The reason for this is that the word "resource" is overloaded in OpenTelemetry,
55
Use the word "asset" consistently where appropriate.
66

77
## Top-level Asset Commands
8-
- Use **plural form**: `dashboards`, `views`, `check-rules`, `synthetic-checks`, `slos`, `recording-rules`, `notification-channels`, `spam-filters`
8+
- Use **plural form**: `dashboards`, `views`, `check-rules`, `synthetic-checks`, `slos`, `recording-rules`, `notification-channels`, `spam-filters`, `teams`
99
- Use **kebab-case** for multi-word names: `check-rules`, `synthetic-checks`, `recording-rules`, `notification-channels`, `spam-filters`
1010
- Group related functionality: `config profiles` for profile management
1111

1212
## Standard CRUD Subcommands for Assets
13-
All asset commands (`dashboards`, `check-rules`, `views`, `synthetic-checks`, `slos`, `recording-rules`, `notification-channels`, `spam-filters`) use these subcommands:
13+
All asset commands (`dashboards`, `check-rules`, `views`, `synthetic-checks`, `slos`, `recording-rules`, `notification-channels`, `spam-filters`), along with the organization-level `teams` command, use these subcommands:
1414

1515
| Subcommand | Alias | Description |
1616
|------------|----------|--------------------------------------|

docs/commands.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -494,8 +494,9 @@ Profile: prod (from DASH0_PROFILE environment variable)
494494
Asset CRUD commands create, list, get, update, and delete Dash0 assets.
495495
Dash0 calls dashboards, views, synthetic checks, and check rules "assets" (not "resources", which is an overloaded term in OpenTelemetry).
496496
497-
All eight asset types (`dashboards`, `check-rules`, `synthetic-checks`, `slos`, `views`, `recording-rules`, `notification-channels`, `spam-filters`) share the same CRUD subcommands.
498-
The examples below use `dashboards`, but the same patterns apply to every asset type.
497+
All nine of these command groups (`dashboards`, `check-rules`, `synthetic-checks`, `slos`, `views`, `recording-rules`, `notification-channels`, `spam-filters`, and the organization-level `teams`) share the same CRUD subcommands.
498+
The first eight are dataset-scoped assets; `teams` is organization-level (no `--dataset`, no `apply`) but shares the same subcommand shape.
499+
The examples below use `dashboards`, but the same patterns apply to every command group.
499500
500501
### `list`
501502

internal/asset/slo.go

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,46 @@ import (
66
dash0api "github.com/dash0hq/dash0-api-client-go"
77
)
88

9-
// ImportSLO creates or updates an SLO via the standard CRUD APIs.
10-
// When the input has a user-defined ID, UPDATE is always used — PUT has
11-
// create-or-replace semantics, so this is idempotent regardless of whether the
12-
// SLO already exists.
13-
// When the input has no ID, CREATE is used and the server assigns an ID.
9+
// ImportSLO creates or upserts an SLO via the standard CRUD APIs.
10+
//
11+
// Upsert key selection mirrors ImportTeam. The SLO API routes GET, PUT, and
12+
// DELETE by origin-or-id (unlike dashboards, views, check rules, and synthetic
13+
// checks, where the server treats dash0.com/origin as provenance metadata and
14+
// it must not be used as an upsert key). So:
15+
//
16+
// - Prefer the stable dash0.com/id when present.
17+
// - Fall back to dash0.com/origin when there is no id. An origin-only
18+
// document (e.g. a UI CR download) must upsert via PUT on that origin
19+
// rather than POST a fresh duplicate on every apply — the #227 team bug.
20+
// - Only POST (server assigns id and origin) when neither is present.
21+
//
22+
// PUT is create-or-replace, so upserting on either key is idempotent across
23+
// repeated applies. The origin label is captured before StripSLOServerFields
24+
// runs because that helper clears dash0.com/origin along with the other
25+
// server-managed labels.
1426
func ImportSLO(ctx context.Context, apiClient dash0api.Client, slo *dash0api.SloDefinition, dataset *string) (ImportResult, error) {
27+
// Capture identifiers before stripping — StripSLOServerFields clears the
28+
// dash0.com/origin label, so origin-based routing must observe the input
29+
// first.
30+
origin := ""
31+
if slo.Metadata.Labels != nil && slo.Metadata.Labels.Dash0Comorigin != nil {
32+
origin = *slo.Metadata.Labels.Dash0Comorigin
33+
}
34+
id := dash0api.GetSLOID(slo)
1535
dash0api.StripSLOServerFields(slo)
1636

37+
var upsertKey string
38+
switch {
39+
case id != "":
40+
upsertKey = id
41+
case origin != "":
42+
upsertKey = origin
43+
}
44+
1745
action := ActionCreated
1846
var before any
19-
id := dash0api.GetSLOID(slo)
20-
if id != "" {
21-
existing, err := apiClient.GetSLO(ctx, id, dataset)
47+
if upsertKey != "" {
48+
existing, err := apiClient.GetSLO(ctx, upsertKey, dataset)
2249
if err == nil {
2350
action = ActionUpdated
2451
before = existing
@@ -27,8 +54,8 @@ func ImportSLO(ctx context.Context, apiClient dash0api.Client, slo *dash0api.Slo
2754

2855
var result *dash0api.SloDefinition
2956
var err error
30-
if id != "" {
31-
result, err = apiClient.UpdateSLO(ctx, id, slo, dataset)
57+
if upsertKey != "" {
58+
result, err = apiClient.UpdateSLO(ctx, upsertKey, slo, dataset)
3259
} else {
3360
result, err = apiClient.CreateSLO(ctx, slo, dataset)
3461
}

internal/slos/delete.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,11 @@ func runDelete(ctx context.Context, id string, flags *asset.DeleteFlags) error {
5555

5656
err = apiClient.DeleteSLO(ctx, id, client.ResolveDataset(ctx, flags.Dataset))
5757
if err != nil {
58-
return client.HandleAPIError(err, client.ErrorContext{
59-
AssetType: "SLO",
60-
AssetID: id,
61-
})
58+
ectx := client.ErrorContext{AssetType: "SLO", AssetID: id}
59+
if client.IsAlreadyDeleted(err, flags.Force, ectx) {
60+
return nil
61+
}
62+
return client.HandleAPIError(err, ectx)
6263
}
6364

6465
fmt.Printf("SLO %q deleted\n", id)

internal/slos/integration_test.go

Lines changed: 187 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const (
2121
apiPathSLOs = "/api/slos"
2222
testAuthToken = "auth_test_token"
2323
testSLOID = "00000000-0000-0000-0000-000000000001"
24+
testSLOID2 = "00000000-0000-0000-0000-000000000002"
2425
fixtureListSuccess = "slos/list_success.json"
2526
fixtureListEmpty = "slos/list_empty.json"
2627
fixtureGetSuccess = "slos/get_success.json"
@@ -43,7 +44,8 @@ func TestListSLOs_JSONFormat(t *testing.T) {
4344
})
4445

4546
cmd := NewSlosCmd()
46-
cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "json", "--limit", "2"})
47+
// The fixture carries two SLOs; --limit 1 must truncate to the first.
48+
cmd.SetArgs([]string{"list", "--api-url", server.URL, "--auth-token", testAuthToken, "-o", "json", "--limit", "1"})
4749

4850
var err error
4951
output := testutil.CaptureStdout(t, func() {
@@ -55,6 +57,9 @@ func TestListSLOs_JSONFormat(t *testing.T) {
5557
assert.Contains(t, output, `"apiVersion": "openslo.com/v1"`)
5658
assert.Contains(t, output, `"metadata"`)
5759
assert.Contains(t, output, `"spec"`)
60+
// --limit 1 truncates: only the first SLO is emitted, not the second.
61+
assert.Contains(t, output, testSLOID)
62+
assert.NotContains(t, output, testSLOID2)
5863
}
5964

6065
func TestListSLOs_YAMLFormat(t *testing.T) {
@@ -357,6 +362,141 @@ func TestDeleteSLO_Success(t *testing.T) {
357362
assert.Contains(t, output, "deleted")
358363
}
359364

365+
// TestDeleteSLO_ForceIdempotentOn404 asserts that `delete --force` treats an
366+
// already-deleted SLO as success (exit 0) and prints an "already deleted" line
367+
// to stderr. Regression coverage matching CHANGELOG 1.16.2 / issue #217.
368+
func TestDeleteSLO_ForceIdempotentOn404(t *testing.T) {
369+
testutil.SetupTestEnv(t)
370+
371+
server := testutil.NewMockServer(t, testutil.FixturesDir())
372+
server.OnPattern(http.MethodDelete, sloIDPattern, testutil.MockResponse{
373+
StatusCode: http.StatusNotFound,
374+
BodyFile: fixtureNotFound,
375+
Validator: testutil.RequireHeaders,
376+
})
377+
378+
cmd := NewSlosCmd()
379+
cmd.SetArgs([]string{"delete", testSLOID, "--force", "--api-url", server.URL, "--auth-token", testAuthToken})
380+
381+
var err error
382+
stderr := testutil.CaptureStderr(t, func() {
383+
err = cmd.Execute()
384+
})
385+
386+
require.NoError(t, err, "expected exit 0 with --force on 404")
387+
assert.Contains(t, stderr, "was already deleted")
388+
assert.Contains(t, stderr, testSLOID)
389+
}
390+
391+
// TestUpdateSLO_StripsServerFields pins the strip contract: an input that
392+
// still carries server-managed fields (as an exported `slos get -o yaml`
393+
// would) must not send dash0.com/version, dash0.com/origin, or the
394+
// created-at/updated-at timestamps back on the wire.
395+
func TestUpdateSLO_StripsServerFields(t *testing.T) {
396+
testutil.SetupTestEnv(t)
397+
398+
server := testutil.NewMockServer(t, testutil.FixturesDir())
399+
server.OnPattern(http.MethodGet, sloIDPattern, testutil.MockResponse{
400+
StatusCode: http.StatusOK,
401+
BodyFile: fixtureGetSuccess,
402+
Validator: testutil.RequireHeaders,
403+
})
404+
server.OnPattern(http.MethodPut, sloIDPattern, testutil.MockResponse{
405+
StatusCode: http.StatusOK,
406+
BodyFile: fixtureUpdateSuccess,
407+
Validator: testutil.RequireHeaders,
408+
})
409+
410+
tmpDir := t.TempDir()
411+
yamlFile := filepath.Join(tmpDir, "slo.yaml")
412+
require.NoError(t, os.WriteFile(yamlFile, []byte(sloUpdateWithServerFieldsYAML), 0644))
413+
414+
cmd := NewSlosCmd()
415+
cmd.SetArgs([]string{"update", "-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken})
416+
417+
var err error
418+
testutil.CaptureStdout(t, func() {
419+
err = cmd.Execute()
420+
})
421+
require.NoError(t, err)
422+
423+
req := server.LastRequest()
424+
require.NotNil(t, req)
425+
assert.Equal(t, http.MethodPut, req.Method)
426+
427+
// Decode the wire body and assert the server-managed fields are absent.
428+
var sent dash0api.SloDefinition
429+
require.NoError(t, json.Unmarshal(req.Body, &sent))
430+
require.NotNil(t, sent.Metadata.Labels)
431+
assert.Nil(t, sent.Metadata.Labels.Dash0Comversion, "dash0.com/version must be stripped")
432+
assert.Nil(t, sent.Metadata.Labels.Dash0Comorigin, "dash0.com/origin must be stripped")
433+
if sent.Metadata.Annotations != nil {
434+
_, hasCreatedAt := sent.Metadata.Annotations.Get("dash0.com/created-at")
435+
assert.False(t, hasCreatedAt, "dash0.com/created-at must be stripped")
436+
}
437+
// Belt-and-suspenders: the raw body must not carry the stripped keys.
438+
body := string(req.Body)
439+
assert.NotContains(t, body, "dash0.com/version")
440+
assert.NotContains(t, body, "dash0.com/created-at")
441+
assert.NotContains(t, body, "dash0.com/origin")
442+
}
443+
444+
// TestCreateSLO_DryRun asserts that `create --dry-run` validates without
445+
// touching the API.
446+
func TestCreateSLO_DryRun(t *testing.T) {
447+
testutil.SetupTestEnv(t)
448+
449+
server := testutil.NewMockServer(t, testutil.FixturesDir())
450+
451+
tmpDir := t.TempDir()
452+
yamlFile := filepath.Join(tmpDir, "slo.yaml")
453+
require.NoError(t, os.WriteFile(yamlFile, []byte(sloCreateYAML), 0644))
454+
455+
cmd := NewSlosCmd()
456+
cmd.SetArgs([]string{"create", "-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken, "--dry-run"})
457+
458+
var err error
459+
output := testutil.CaptureStdout(t, func() {
460+
err = cmd.Execute()
461+
})
462+
463+
require.NoError(t, err)
464+
assert.Contains(t, output, "Dry run")
465+
// No API call must have been made.
466+
assert.Nil(t, server.LastRequest())
467+
}
468+
469+
// TestUpdateSLO_DryRun asserts that `update --dry-run` fetches the current
470+
// state for the diff but never issues the PUT.
471+
func TestUpdateSLO_DryRun(t *testing.T) {
472+
testutil.SetupTestEnv(t)
473+
474+
server := testutil.NewMockServer(t, testutil.FixturesDir())
475+
server.OnPattern(http.MethodGet, sloIDPattern, testutil.MockResponse{
476+
StatusCode: http.StatusOK,
477+
BodyFile: fixtureGetSuccess,
478+
Validator: testutil.RequireHeaders,
479+
})
480+
481+
tmpDir := t.TempDir()
482+
yamlFile := filepath.Join(tmpDir, "slo.yaml")
483+
require.NoError(t, os.WriteFile(yamlFile, []byte(sloUpdateYAML), 0644))
484+
485+
cmd := NewSlosCmd()
486+
cmd.SetArgs([]string{"update", "-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken, "--dry-run"})
487+
488+
var err error
489+
testutil.CaptureStdout(t, func() {
490+
err = cmd.Execute()
491+
})
492+
493+
require.NoError(t, err)
494+
// The only request must be the GET used to build the diff — never a PUT.
495+
req := server.LastRequest()
496+
require.NotNil(t, req)
497+
assert.Equal(t, http.MethodGet, req.Method)
498+
}
499+
360500
const sloCreateYAML = `apiVersion: openslo.com/v1
361501
kind: SLO
362502
metadata:
@@ -428,3 +568,49 @@ spec:
428568
- displayName: 99.5% availability
429569
target: 0.995
430570
`
571+
572+
// sloUpdateWithServerFieldsYAML mirrors the shape of an exported
573+
// `slos get -o yaml`: it carries the server-managed dash0.com/version,
574+
// dash0.com/dataset, and dash0.com/origin labels plus the created-at/updated-at
575+
// annotations. The update path must strip all of these before the PUT.
576+
const sloUpdateWithServerFieldsYAML = `apiVersion: openslo.com/v1
577+
kind: SLO
578+
metadata:
579+
name: checkout-availability
580+
labels:
581+
dash0.com/id: 00000000-0000-0000-0000-000000000001
582+
dash0.com/version: "1"
583+
dash0.com/dataset: default
584+
dash0.com/origin: terraform
585+
annotations:
586+
dash0.com/display-name: Checkout availability
587+
dash0.com/enabled: "true"
588+
dash0.com/created-at: "2026-01-15T10:00:00Z"
589+
dash0.com/updated-at: "2026-01-15T10:00:00Z"
590+
spec:
591+
description: 99 percent of checkout HTTP requests succeed over a rolling 28-day window.
592+
service: checkout
593+
budgetingMethod: Occurrences
594+
timeWindow:
595+
- duration: 28d
596+
isRolling: true
597+
indicator:
598+
metadata:
599+
name: checkout-success-ratio
600+
spec:
601+
ratioMetric:
602+
counter: true
603+
good:
604+
metricSource:
605+
type: Prometheus
606+
spec:
607+
query: 'http_server_request_duration_seconds_count{service_name="checkout",http_response_status_code!~"5.."}'
608+
total:
609+
metricSource:
610+
type: Prometheus
611+
spec:
612+
query: 'http_server_request_duration_seconds_count{service_name="checkout"}'
613+
objectives:
614+
- displayName: 99% availability
615+
target: 0.99
616+
`

internal/slos/list.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func runList(ctx context.Context, flags *asset.ListFlags) error {
8383

8484
switch format {
8585
case output.FormatJSON, output.FormatYAML:
86-
definitions := make([]interface{}, 0, len(items))
86+
definitions := make([]any, 0, len(items))
8787
for _, slo := range items {
8888
definitions = append(definitions, slo)
8989
}
@@ -98,20 +98,20 @@ func runList(ctx context.Context, flags *asset.ListFlags) error {
9898

9999
func printSLOTable(f *output.Formatter, items []*dash0api.SloDefinition, format output.Format, apiUrl string, dataset *string) error {
100100
columns := []output.Column{
101-
{Header: internal.HEADER_NAME, Width: 40, Value: func(item interface{}) string {
101+
{Header: internal.HEADER_NAME, Width: 40, Value: func(item any) string {
102102
return dash0api.GetSLOName(item.(*dash0api.SloDefinition))
103103
}},
104-
{Header: internal.HEADER_ID, Width: 36, Value: func(item interface{}) string {
104+
{Header: internal.HEADER_ID, Width: 36, Value: func(item any) string {
105105
return dash0api.GetSLOID(item.(*dash0api.SloDefinition))
106106
}},
107107
}
108108

109109
if format == output.FormatWide || format == output.FormatCSV {
110110
columns = append(columns,
111-
output.Column{Header: internal.HEADER_DATASET, Width: 15, Value: func(item interface{}) string {
111+
output.Column{Header: internal.HEADER_DATASET, Width: 15, Value: func(item any) string {
112112
return dash0api.GetSLODataset(item.(*dash0api.SloDefinition))
113113
}},
114-
output.Column{Header: internal.HEADER_URL, Width: 70, Value: func(item interface{}) string {
114+
output.Column{Header: internal.HEADER_URL, Width: 70, Value: func(item any) string {
115115
return dash0api.DeeplinkURL(apiUrl, dash0api.DeeplinkAssetTypeSLO, dash0api.GetSLOID(item.(*dash0api.SloDefinition)), dataset)
116116
}},
117117
)
@@ -122,7 +122,7 @@ func printSLOTable(f *output.Formatter, items []*dash0api.SloDefinition, format
122122
return nil
123123
}
124124

125-
data := make([]interface{}, len(items))
125+
data := make([]any, len(items))
126126
for i, s := range items {
127127
data[i] = s
128128
}

internal/slos/update.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ func runUpdate(ctx context.Context, args []string, flags *asset.FileInputFlags)
7676
})
7777
}
7878

79+
// Strip server-managed fields before diffing or sending the body back. An
80+
// exported SLO (e.g. via `dash0 slos get -o yaml`) carries dash0.com/origin
81+
// plus version, dataset, source, and created/updated timestamps, which the
82+
// server rejects or echoes back as spurious diff noise (the same class of
83+
// "origin does not match" 400 that bit views). The apply code path
84+
// (asset.ImportSLO) does the same strip; without it, `apply -f` and
85+
// `update -f` diverge on the same exported file.
86+
dash0api.StripSLOServerFields(&slo)
87+
7988
if flags.DryRun {
8089
return asset.PrintDiff(os.Stdout, "SLO", slo.Metadata.Name, before, &slo)
8190
}

0 commit comments

Comments
 (0)