From 49b91e21b5ea85fb702c9ca3f2a7a43c45465694 Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 9 Mar 2026 16:57:37 +0100 Subject: [PATCH] fix: move away from using Import* APIs --- .chloggen/do-not-use-import-apis.yaml | 28 +++ internal/apply/integration_test.go | 169 +++++++++--------- internal/asset/checkrule.go | 16 +- internal/asset/dashboard.go | 18 +- internal/asset/syntheticcheck.go | 16 +- internal/asset/view.go | 16 +- internal/testutil/mockserver.go | 70 ++++---- test/manual/fixtures/check-rule.yaml | 2 - test/manual/fixtures/dashboard.yaml | 8 - test/manual/fixtures/synthetic-check.yaml | 19 -- test/manual/fixtures/view.yaml | 15 -- test/manual/test_dashboard_roundtrip.sh | 4 +- test/manual/test_synthetic_check_roundtrip.sh | 6 +- test/manual/test_view_roundtrip.sh | 6 +- 14 files changed, 199 insertions(+), 194 deletions(-) create mode 100644 .chloggen/do-not-use-import-apis.yaml diff --git a/.chloggen/do-not-use-import-apis.yaml b/.chloggen/do-not-use-import-apis.yaml new file mode 100644 index 0000000..c1dda5d --- /dev/null +++ b/.chloggen/do-not-use-import-apis.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: enhancement + +# The name of the component, or a single word describing the area of concern (e.g. dashboards, config, apply) +component: apply + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Migrate asset create/update from Import APIs to standard CRUD APIs + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [90] + +# (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: | + The `apply` command and individual asset `create`/`update` subcommands now use the standard + Create and Update APIs instead of the Import APIs, which are intended for one-time migrations. + +# 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/internal/apply/integration_test.go b/internal/apply/integration_test.go index 4586030..3de6b3a 100644 --- a/internal/apply/integration_test.go +++ b/internal/apply/integration_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "testing" "github.com/dash0hq/dash0-cli/internal/testutil" @@ -17,11 +18,11 @@ import ( const ( testAuthToken = "auth_test_token" - // Import API paths - apiPathImportDashboard = "/api/import/dashboard" - apiPathImportCheckRule = "/api/import/check-rule" - apiPathImportView = "/api/import/view" - apiPathImportSyntheticCheck = "/api/import/synthetic-check" + // Standard CRUD API paths + apiPathDashboards = "/api/dashboards" + apiPathCheckRules = "/api/alerting/check-rules" + apiPathViews = "/api/views" + apiPathSyntheticChecks = "/api/synthetic-checks" ) var ( @@ -50,7 +51,7 @@ expression: up == 0 BodyFile: testutil.FixtureCheckRulesNotFound, }) // Import succeeds - server.WithCheckRuleImport(testutil.FixtureCheckRulesImportSuccess) + server.WithCheckRulesCreate(testutil.FixtureCheckRulesImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -85,8 +86,8 @@ expression: up == 0 BodyFile: testutil.FixtureCheckRulesImportSuccess, Validator: testutil.RequireHeaders, }) - // Import succeeds - server.WithCheckRuleImport(testutil.FixtureCheckRulesImportSuccess) + // Update succeeds + server.WithCheckRulesUpdate(testutil.FixtureCheckRulesImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -97,14 +98,14 @@ expression: up == 0 }) require.NoError(t, cmdErr) - // Output is a diff; since GET and Import return the same fixture, expect "no changes" + // Output is a diff; since GET and Update return the same fixture, expect "no changes" assert.Contains(t, output, "Check rule") assert.Contains(t, output, "no changes") - // Verify the import request body - importReq := findImportRequest(server.Requests(), apiPathImportCheckRule) - require.NotNil(t, importReq, "expected an import request for check rule") - body := string(importReq.Body) + // Verify the update request body + updateReq := findRequest(server.Requests(), http.MethodPut, apiPathCheckRules) + require.NotNil(t, updateReq, "expected an update request for check rule") + body := string(updateReq.Body) assert.NotContains(t, body, "dash0.com/origin") assert.Contains(t, body, "47b6ccbe-82ab-47c6-a613-ce0d7f34353e") } @@ -135,7 +136,7 @@ spec: BodyFile: testutil.FixtureDashboardsNotFound, }) // Import succeeds - server.WithDashboardImport(testutil.FixtureDashboardsImportSuccess) + server.WithDashboardsCreate(testutil.FixtureDashboardsImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -178,8 +179,8 @@ spec: BodyFile: testutil.FixtureDashboardsImportSuccess, Validator: testutil.RequireHeaders, }) - // Import succeeds - server.WithDashboardImport(testutil.FixtureDashboardsImportSuccess) + // Update succeeds + server.WithDashboardsUpdate(testutil.FixtureDashboardsImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -190,14 +191,14 @@ spec: }) require.NoError(t, cmdErr) - // Output is a diff; since GET and Import return the same fixture, expect "no changes" + // Output is a diff; since GET and Update return the same fixture, expect "no changes" assert.Contains(t, output, "Dashboard") assert.Contains(t, output, "no changes") - // Verify the import request body has server-generated fields stripped - importReq := findImportRequest(server.Requests(), apiPathImportDashboard) - require.NotNil(t, importReq, "expected an import request for dashboard") - body := string(importReq.Body) + // Verify the update request body has server-generated fields stripped + updateReq := findRequest(server.Requests(), http.MethodPut, apiPathDashboards) + require.NotNil(t, updateReq, "expected an update request for dashboard") + body := string(updateReq.Body) assert.NotContains(t, body, `"createdAt"`) assert.NotContains(t, body, `"updatedAt"`) assert.NotContains(t, body, `"version"`) @@ -227,7 +228,7 @@ expression: down == 1 BodyFile: testutil.FixtureCheckRulesNotFound, }) // Import succeeds - server.WithCheckRuleImport(testutil.FixtureCheckRulesImportSuccess) + server.WithCheckRulesCreate(testutil.FixtureCheckRulesImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -324,7 +325,7 @@ func TestApply_FromStdin(t *testing.T) { StatusCode: http.StatusNotFound, BodyFile: testutil.FixtureCheckRulesNotFound, }) - server.WithCheckRuleImport(testutil.FixtureCheckRulesImportSuccess) + server.WithCheckRulesCreate(testutil.FixtureCheckRulesImportSuccess) // Create a temp file to simulate stdin tmpDir := t.TempDir() @@ -387,7 +388,7 @@ spec: StatusCode: http.StatusNotFound, BodyFile: testutil.FixtureViewsNotFound, }) - server.WithViewImport(testutil.FixtureViewsImportSuccess) + server.WithViewsCreate(testutil.FixtureViewsImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -429,7 +430,7 @@ spec: StatusCode: http.StatusNotFound, BodyFile: testutil.FixtureSyntheticChecksNotFound, }) - server.WithSyntheticCheckImport(testutil.FixtureSyntheticChecksImportSuccess) + server.WithSyntheticChecksCreate(testutil.FixtureSyntheticChecksImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -497,7 +498,7 @@ spec: StatusCode: http.StatusNotFound, BodyFile: testutil.FixtureCheckRulesNotFound, }) - server.WithCheckRuleImport(testutil.FixtureCheckRulesImportSuccess) + server.WithCheckRulesCreate(testutil.FixtureCheckRulesImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -534,7 +535,7 @@ spec: StatusCode: http.StatusNotFound, BodyFile: testutil.FixtureDashboardsNotFound, }) - server.WithDashboardImport(testutil.FixtureDashboardsImportSuccess) + server.WithDashboardsCreate(testutil.FixtureDashboardsImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -549,10 +550,10 @@ spec: assert.Contains(t, output, "Dashboard") assert.Contains(t, output, "created") - // Verify the import request contains the Perses spec fields - importReq := findImportRequest(server.Requests(), apiPathImportDashboard) - require.NotNil(t, importReq, "expected an import request for dashboard") - body := string(importReq.Body) + // Verify the create request contains the Perses spec fields + createReq := findRequest(server.Requests(), http.MethodPost, apiPathDashboards) + require.NotNil(t, createReq, "expected a create request for dashboard") + body := string(createReq.Body) assert.Contains(t, body, "Test Perses Dashboard") assert.Contains(t, body, "5m") } @@ -579,7 +580,7 @@ spec: StatusCode: http.StatusNotFound, BodyFile: testutil.FixtureDashboardsNotFound, }) - server.WithDashboardImport(testutil.FixtureDashboardsImportSuccess) + server.WithDashboardsCreate(testutil.FixtureDashboardsImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -594,9 +595,9 @@ spec: assert.Contains(t, output, "created") // Verify the spec.config wrapper was unwrapped - importReq := findImportRequest(server.Requests(), apiPathImportDashboard) - require.NotNil(t, importReq, "expected an import request for dashboard") - body := string(importReq.Body) + createReq := findRequest(server.Requests(), http.MethodPost, apiPathDashboards) + require.NotNil(t, createReq, "expected a create request for dashboard") + body := string(createReq.Body) assert.Contains(t, body, "V1Alpha2 Dashboard") assert.Contains(t, body, "10m") } @@ -627,12 +628,12 @@ spec: StatusCode: http.StatusNotFound, BodyFile: testutil.FixtureCheckRulesNotFound, }) - server.WithCheckRuleImport(testutil.FixtureCheckRulesImportSuccess) + server.WithCheckRulesCreate(testutil.FixtureCheckRulesImportSuccess) server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{ StatusCode: http.StatusNotFound, BodyFile: testutil.FixtureViewsNotFound, }) - server.WithViewImport(testutil.FixtureViewsImportSuccess) + server.WithViewsCreate(testutil.FixtureViewsImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", dir, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -667,7 +668,7 @@ expression: down == 1 StatusCode: http.StatusNotFound, BodyFile: testutil.FixtureCheckRulesNotFound, }) - server.WithCheckRuleImport(testutil.FixtureCheckRulesImportSuccess) + server.WithCheckRulesCreate(testutil.FixtureCheckRulesImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", dir, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -824,7 +825,7 @@ spec: Validator: testutil.RequireHeaders, }) // Import succeeds - server.WithDashboardImport(testutil.FixtureDashboardsImportSuccess) + server.WithDashboardsCreate(testutil.FixtureDashboardsImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -838,13 +839,11 @@ spec: assert.Contains(t, output, "Dashboard") assert.Contains(t, output, "created") - // Verify the import request body has dash0Extensions.id stripped (replaced with a new UUID) - for _, req := range server.Requests() { - if req.Method == http.MethodPost && req.Path == apiPathImportDashboard { - body := string(req.Body) - assert.NotContains(t, body, "deleted-dashboard-uuid") - } - } + // Verify the create request body has dash0Extensions.id stripped (replaced with a new UUID) + createReq := findRequest(server.Requests(), http.MethodPost, apiPathDashboards) + require.NotNil(t, createReq, "expected a create request for dashboard") + body := string(createReq.Body) + assert.NotContains(t, body, "deleted-dashboard-uuid") } func TestApply_CheckRule_Created_StripsId(t *testing.T) { @@ -867,8 +866,8 @@ expression: up == 0 BodyFile: testutil.FixtureCheckRulesNotFound, Validator: testutil.RequireHeaders, }) - // Import succeeds - server.WithCheckRuleImport(testutil.FixtureCheckRulesImportSuccess) + // Create succeeds + server.WithCheckRulesCreate(testutil.FixtureCheckRulesImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -882,13 +881,11 @@ expression: up == 0 assert.Contains(t, output, "Check rule") assert.Contains(t, output, "created") - // Verify the import request body has the ID stripped - for _, req := range server.Requests() { - if req.Method == http.MethodPost && req.Path == apiPathImportCheckRule { - body := string(req.Body) - assert.NotContains(t, body, "deleted-rule-uuid") - } - } + // Verify the create request body has the ID stripped + createReq := findRequest(server.Requests(), http.MethodPost, apiPathCheckRules) + require.NotNil(t, createReq, "expected a create request for check rule") + body := string(createReq.Body) + assert.NotContains(t, body, "deleted-rule-uuid") } func TestApply_View_Updated(t *testing.T) { @@ -920,8 +917,8 @@ spec: BodyFile: testutil.FixtureViewsImportSuccess, Validator: testutil.RequireHeaders, }) - // Import succeeds - server.WithViewImport(testutil.FixtureViewsImportSuccess) + // Update succeeds + server.WithViewsUpdate(testutil.FixtureViewsImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -932,14 +929,14 @@ spec: }) require.NoError(t, cmdErr) - // Output is a diff; since GET and Import return the same fixture, expect "no changes" + // Output is a diff; since GET and Update return the same fixture, expect "no changes" assert.Contains(t, output, "View") assert.Contains(t, output, "no changes") - // Verify the import request body has server-generated fields stripped - importReq := findImportRequest(server.Requests(), apiPathImportView) - require.NotNil(t, importReq, "expected an import request for view") - body := string(importReq.Body) + // Verify the update request body has server-generated fields stripped + updateReq := findRequest(server.Requests(), http.MethodPut, apiPathViews) + require.NotNil(t, updateReq, "expected an update request for view") + body := string(updateReq.Body) assert.NotContains(t, body, `"dash0.com/origin"`) assert.NotContains(t, body, `"dash0.com/version"`) assert.NotContains(t, body, `"dash0.com/source"`) @@ -980,8 +977,8 @@ spec: BodyFile: testutil.FixtureSyntheticChecksImportSuccess, Validator: testutil.RequireHeaders, }) - // Import succeeds - server.WithSyntheticCheckImport(testutil.FixtureSyntheticChecksImportSuccess) + // Update succeeds + server.WithSyntheticChecksUpdate(testutil.FixtureSyntheticChecksImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -992,14 +989,14 @@ spec: }) require.NoError(t, cmdErr) - // Output is a diff; since GET and Import return the same fixture, expect "no changes" + // Output is a diff; since GET and Update return the same fixture, expect "no changes" assert.Contains(t, output, "Synthetic check") assert.Contains(t, output, "no changes") - // Verify the import request body has server-generated fields stripped - importReq := findImportRequest(server.Requests(), apiPathImportSyntheticCheck) - require.NotNil(t, importReq, "expected an import request for synthetic check") - body := string(importReq.Body) + // Verify the update request body has server-generated fields stripped + updateReq := findRequest(server.Requests(), http.MethodPut, apiPathSyntheticChecks) + require.NotNil(t, updateReq, "expected an update request for synthetic check") + body := string(updateReq.Body) assert.NotContains(t, body, `"dash0.com/origin"`) assert.NotContains(t, body, `"dash0.com/version"`) assert.NotContains(t, body, `"dash0.com/dataset"`) @@ -1018,10 +1015,10 @@ func countOccurrences(s, substr string) int { return count } -// findImportRequest finds the first POST request to the given import API path. -func findImportRequest(requests []testutil.RecordedRequest, path string) *testutil.RecordedRequest { +// findRequest finds the first request matching the given method whose path starts with pathPrefix. +func findRequest(requests []testutil.RecordedRequest, method string, pathPrefix string) *testutil.RecordedRequest { for _, req := range requests { - if req.Method == http.MethodPost && req.Path == path { + if req.Method == method && strings.HasPrefix(req.Path, pathPrefix) { return &req } } @@ -1060,7 +1057,7 @@ spec: StatusCode: http.StatusNotFound, BodyFile: testutil.FixtureViewsNotFound, }) - server.WithViewImport(testutil.FixtureViewsImportSuccess) + server.WithViewsCreate(testutil.FixtureViewsImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -1075,9 +1072,9 @@ spec: assert.Contains(t, output, "created") // Verify filter values survive the YAML parse → JSON serialize round-trip - importReq := findImportRequest(server.Requests(), apiPathImportView) - require.NotNil(t, importReq, "expected an import request for view") - body := string(importReq.Body) + createReq := findRequest(server.Requests(), http.MethodPost, apiPathViews) + require.NotNil(t, createReq, "expected a create request for view") + body := string(createReq.Body) assert.Contains(t, body, "ERROR") assert.Contains(t, body, "FATAL") assert.Contains(t, body, "my-service") @@ -1113,7 +1110,7 @@ spec: BodyFile: testutil.FixtureViewsNotFound, Validator: testutil.RequireHeaders, }) - server.WithViewImport(testutil.FixtureViewsImportSuccess) + server.WithViewsCreate(testutil.FixtureViewsImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -1127,10 +1124,10 @@ spec: assert.Contains(t, output, "View") assert.Contains(t, output, "created") - // Verify the import request body has the ID stripped - importReq := findImportRequest(server.Requests(), apiPathImportView) - require.NotNil(t, importReq, "expected an import request for view") - body := string(importReq.Body) + // Verify the create request body has the ID stripped + createReq := findRequest(server.Requests(), http.MethodPost, apiPathViews) + require.NotNil(t, createReq, "expected a create request for view") + body := string(createReq.Body) assert.NotContains(t, body, "deleted-view-uuid") } @@ -1165,7 +1162,7 @@ spec: BodyFile: testutil.FixtureSyntheticChecksNotFound, Validator: testutil.RequireHeaders, }) - server.WithSyntheticCheckImport(testutil.FixtureSyntheticChecksImportSuccess) + server.WithSyntheticChecksCreate(testutil.FixtureSyntheticChecksImportSuccess) cmd := NewApplyCmd() cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) @@ -1179,9 +1176,9 @@ spec: assert.Contains(t, output, "Synthetic check") assert.Contains(t, output, "created") - // Verify the import request body has the ID stripped - importReq := findImportRequest(server.Requests(), apiPathImportSyntheticCheck) - require.NotNil(t, importReq, "expected an import request for synthetic check") - body := string(importReq.Body) + // Verify the create request body has the ID stripped + createReq := findRequest(server.Requests(), http.MethodPost, apiPathSyntheticChecks) + require.NotNil(t, createReq, "expected a create request for synthetic check") + body := string(createReq.Body) assert.NotContains(t, body, "deleted-check-uuid") } diff --git a/internal/asset/checkrule.go b/internal/asset/checkrule.go index 0ed4c0b..2af18ab 100644 --- a/internal/asset/checkrule.go +++ b/internal/asset/checkrule.go @@ -17,30 +17,38 @@ func StripCheckRuleServerFields(r *dash0api.PrometheusAlertRule) { } // ImportCheckRule checks existence by rule ID, strips the ID when the asset -// is not found, and calls the import API. +// is not found, and creates or updates the check rule via the standard CRUD APIs. func ImportCheckRule(ctx context.Context, apiClient dash0api.Client, rule *dash0api.PrometheusAlertRule, dataset *string) (ImportResult, error) { StripCheckRuleServerFields(rule) // Check if check rule exists action := ActionCreated var before any + id := "" if rule.Id != nil && *rule.Id != "" { - existing, err := apiClient.GetCheckRule(ctx, *rule.Id, dataset) + id = *rule.Id + existing, err := apiClient.GetCheckRule(ctx, id, dataset) if err == nil { action = ActionUpdated before = existing } else { // Asset not found — strip ID so the API creates a fresh asset. rule.Id = nil + id = "" } } - result, err := apiClient.ImportCheckRule(ctx, rule, dataset) + var result *dash0api.PrometheusAlertRule + var err error + if action == ActionUpdated { + result, err = apiClient.UpdateCheckRule(ctx, id, rule, dataset) + } else { + result, err = apiClient.CreateCheckRule(ctx, rule, dataset) + } if err != nil { return ImportResult{}, err } - id := "" if result.Id != nil { id = *result.Id } diff --git a/internal/asset/dashboard.go b/internal/asset/dashboard.go index 8a41796..6fd5879 100644 --- a/internal/asset/dashboard.go +++ b/internal/asset/dashboard.go @@ -21,10 +21,10 @@ func StripDashboardServerFields(d *dash0api.DashboardDefinition) { } // ImportDashboard checks existence by dash0Extensions.id, -// strips server-generated fields when the asset is not found, and calls the -// import API. The import API uses dash0Extensions.id as the upsert key; when -// the input has no id, a fresh UUID is generated so re-applying the exported -// YAML updates the dashboard instead of creating a duplicate. +// strips server-generated fields, and creates or updates the dashboard via the +// standard CRUD APIs. When the input has no id (e.g. fresh YAML), a fresh UUID +// is generated so re-applying the exported YAML updates the dashboard instead +// of creating a duplicate. func ImportDashboard(ctx context.Context, apiClient dash0api.Client, dashboard *dash0api.DashboardDefinition, dataset *string) (ImportResult, error) { StripDashboardServerFields(dashboard) @@ -46,7 +46,7 @@ func ImportDashboard(ctx context.Context, apiClient dash0api.Client, dashboard * } } - // Ensure dash0Extensions.id is set so the import API can perform upserts. + // Ensure dash0Extensions.id is set so the create API assigns a stable ID. // When the input has no id (e.g. fresh YAML), generate a unique one. This // makes re-applying the exported output update the existing dashboard rather // than creating a duplicate. @@ -58,7 +58,13 @@ func ImportDashboard(ctx context.Context, apiClient dash0api.Client, dashboard * dashboard.Metadata.Dash0Extensions.Id = &id } - result, err := apiClient.ImportDashboard(ctx, dashboard, dataset) + var result *dash0api.DashboardDefinition + var err error + if action == ActionUpdated { + result, err = apiClient.UpdateDashboard(ctx, id, dashboard, dataset) + } else { + result, err = apiClient.CreateDashboard(ctx, dashboard, dataset) + } if err != nil { return ImportResult{}, err } diff --git a/internal/asset/syntheticcheck.go b/internal/asset/syntheticcheck.go index c02ccb8..d6367cc 100644 --- a/internal/asset/syntheticcheck.go +++ b/internal/asset/syntheticcheck.go @@ -22,30 +22,38 @@ func StripSyntheticCheckServerFields(c *dash0api.SyntheticCheckDefinition) { } // ImportSyntheticCheck checks existence by Dash0Comid, strips server-generated -// fields, and calls the import API. +// fields, and creates or updates the synthetic check via the standard CRUD APIs. func ImportSyntheticCheck(ctx context.Context, apiClient dash0api.Client, check *dash0api.SyntheticCheckDefinition, dataset *string) (ImportResult, error) { StripSyntheticCheckServerFields(check) // Check if synthetic check exists using its ID action := ActionCreated var before any + id := "" if check.Metadata.Labels.Dash0Comid != nil && *check.Metadata.Labels.Dash0Comid != "" { - existing, err := apiClient.GetSyntheticCheck(ctx, *check.Metadata.Labels.Dash0Comid, dataset) + id = *check.Metadata.Labels.Dash0Comid + existing, err := apiClient.GetSyntheticCheck(ctx, id, dataset) if err == nil { action = ActionUpdated before = existing } else { // Asset not found — strip the ID so the API creates a fresh asset. check.Metadata.Labels.Dash0Comid = nil + id = "" } } - result, err := apiClient.ImportSyntheticCheck(ctx, check, dataset) + var result *dash0api.SyntheticCheckDefinition + var err error + if action == ActionUpdated { + result, err = apiClient.UpdateSyntheticCheck(ctx, id, check, dataset) + } else { + result, err = apiClient.CreateSyntheticCheck(ctx, check, dataset) + } if err != nil { return ImportResult{}, err } - id := "" if result.Metadata.Labels != nil && result.Metadata.Labels.Dash0Comid != nil { id = *result.Metadata.Labels.Dash0Comid } diff --git a/internal/asset/view.go b/internal/asset/view.go index 9502491..a42a6f9 100644 --- a/internal/asset/view.go +++ b/internal/asset/view.go @@ -22,30 +22,38 @@ func StripViewServerFields(v *dash0api.ViewDefinition) { } // ImportView checks existence by Dash0Comid, strips server-generated fields, -// and calls the import API. +// and creates or updates the view via the standard CRUD APIs. func ImportView(ctx context.Context, apiClient dash0api.Client, view *dash0api.ViewDefinition, dataset *string) (ImportResult, error) { StripViewServerFields(view) // Check if view exists using its ID action := ActionCreated var before any + id := "" if view.Metadata.Labels.Dash0Comid != nil && *view.Metadata.Labels.Dash0Comid != "" { - existing, err := apiClient.GetView(ctx, *view.Metadata.Labels.Dash0Comid, dataset) + id = *view.Metadata.Labels.Dash0Comid + existing, err := apiClient.GetView(ctx, id, dataset) if err == nil { action = ActionUpdated before = existing } else { // Asset not found — strip the ID so the API creates a fresh asset. view.Metadata.Labels.Dash0Comid = nil + id = "" } } - result, err := apiClient.ImportView(ctx, view, dataset) + var result *dash0api.ViewDefinition + var err error + if action == ActionUpdated { + result, err = apiClient.UpdateView(ctx, id, view, dataset) + } else { + result, err = apiClient.CreateView(ctx, view, dataset) + } if err != nil { return ImportResult{}, err } - id := "" if result.Metadata.Labels != nil && result.Metadata.Labels.Dash0Comid != nil { id = *result.Metadata.Labels.Dash0Comid } diff --git a/internal/testutil/mockserver.go b/internal/testutil/mockserver.go index ff033f5..e98b876 100644 --- a/internal/testutil/mockserver.go +++ b/internal/testutil/mockserver.go @@ -341,8 +341,9 @@ func (m *MockServer) WithDashboardsGet(fixture string) *MockServer { // WithDashboardsCreate sets up the mock server to accept dashboard creation. func (m *MockServer) WithDashboardsCreate(fixture string) *MockServer { return m.On(http.MethodPost, "/api/dashboards", MockResponse{ - StatusCode: http.StatusCreated, + StatusCode: http.StatusOK, BodyFile: fixture, + Validator: RequireHeaders, }) } @@ -351,6 +352,7 @@ func (m *MockServer) WithDashboardsUpdate(fixture string) *MockServer { return m.OnPattern(http.MethodPut, regexp.MustCompile(`^/api/dashboards/[^/]+$`), MockResponse{ StatusCode: http.StatusOK, BodyFile: fixture, + Validator: RequireHeaders, }) } @@ -380,8 +382,18 @@ func (m *MockServer) WithCheckRulesGet(fixture string) *MockServer { // WithCheckRulesCreate sets up the mock server to accept check rule creation. func (m *MockServer) WithCheckRulesCreate(fixture string) *MockServer { return m.On(http.MethodPost, "/api/alerting/check-rules", MockResponse{ - StatusCode: http.StatusCreated, + StatusCode: http.StatusOK, BodyFile: fixture, + Validator: RequireHeaders, + }) +} + +// WithCheckRulesUpdate sets up the mock server to accept check rule updates. +func (m *MockServer) WithCheckRulesUpdate(fixture string) *MockServer { + return m.OnPattern(http.MethodPut, regexp.MustCompile(`^/api/alerting/check-rules/[^/]+$`), MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixture, + Validator: RequireHeaders, }) } @@ -411,8 +423,18 @@ func (m *MockServer) WithViewsGet(fixture string) *MockServer { // WithViewsCreate sets up the mock server to accept view creation. func (m *MockServer) WithViewsCreate(fixture string) *MockServer { return m.On(http.MethodPost, "/api/views", MockResponse{ - StatusCode: http.StatusCreated, + StatusCode: http.StatusOK, BodyFile: fixture, + Validator: RequireHeaders, + }) +} + +// WithViewsUpdate sets up the mock server to accept view updates. +func (m *MockServer) WithViewsUpdate(fixture string) *MockServer { + return m.OnPattern(http.MethodPut, regexp.MustCompile(`^/api/views/[^/]+$`), MockResponse{ + StatusCode: http.StatusOK, + BodyFile: fixture, + Validator: RequireHeaders, }) } @@ -442,53 +464,25 @@ func (m *MockServer) WithSyntheticChecksGet(fixture string) *MockServer { // WithSyntheticChecksCreate sets up the mock server to accept synthetic check creation. func (m *MockServer) WithSyntheticChecksCreate(fixture string) *MockServer { return m.On(http.MethodPost, "/api/synthetic-checks", MockResponse{ - StatusCode: http.StatusCreated, - BodyFile: fixture, - }) -} - -// WithSyntheticChecksDelete sets up the mock server to accept synthetic check deletion. -func (m *MockServer) WithSyntheticChecksDelete() *MockServer { - return m.OnPattern(http.MethodDelete, regexp.MustCompile(`^/api/synthetic-checks/[^/]+$`), MockResponse{ - StatusCode: http.StatusNoContent, - }) -} - -// --- Import endpoint helpers --- - -// WithDashboardImport sets up the mock server to accept dashboard imports. -func (m *MockServer) WithDashboardImport(fixture string) *MockServer { - return m.On(http.MethodPost, "/api/import/dashboard", MockResponse{ - StatusCode: http.StatusOK, - BodyFile: fixture, - Validator: RequireHeaders, - }) -} - -// WithCheckRuleImport sets up the mock server to accept check rule imports. -func (m *MockServer) WithCheckRuleImport(fixture string) *MockServer { - return m.On(http.MethodPost, "/api/import/check-rule", MockResponse{ StatusCode: http.StatusOK, BodyFile: fixture, Validator: RequireHeaders, }) } -// WithViewImport sets up the mock server to accept view imports. -func (m *MockServer) WithViewImport(fixture string) *MockServer { - return m.On(http.MethodPost, "/api/import/view", MockResponse{ +// WithSyntheticChecksUpdate sets up the mock server to accept synthetic check updates. +func (m *MockServer) WithSyntheticChecksUpdate(fixture string) *MockServer { + return m.OnPattern(http.MethodPut, regexp.MustCompile(`^/api/synthetic-checks/[^/]+$`), MockResponse{ StatusCode: http.StatusOK, BodyFile: fixture, Validator: RequireHeaders, }) } -// WithSyntheticCheckImport sets up the mock server to accept synthetic check imports. -func (m *MockServer) WithSyntheticCheckImport(fixture string) *MockServer { - return m.On(http.MethodPost, "/api/import/synthetic-check", MockResponse{ - StatusCode: http.StatusOK, - BodyFile: fixture, - Validator: RequireHeaders, +// WithSyntheticChecksDelete sets up the mock server to accept synthetic check deletion. +func (m *MockServer) WithSyntheticChecksDelete() *MockServer { + return m.OnPattern(http.MethodDelete, regexp.MustCompile(`^/api/synthetic-checks/[^/]+$`), MockResponse{ + StatusCode: http.StatusNoContent, }) } diff --git a/test/manual/fixtures/check-rule.yaml b/test/manual/fixtures/check-rule.yaml index ecc35c3..c47686d 100644 --- a/test/manual/fixtures/check-rule.yaml +++ b/test/manual/fixtures/check-rule.yaml @@ -1,13 +1,11 @@ annotations: description: There are error logs from Minecraft server summary: Error logs from Minecraft server -dataset: default description: There are error logs from Minecraft server enabled: true expression: sum by (otel_log_severity_range) (increase({otel_metric_name = "dash0.logs", otel_log_severity_range =~ "^(ERROR|FATAL)$"}[2m])) > $__threshold for: 0s -id: 50680c5e-f1a3-4ca9-8da7-86664bbc00d1 interval: 1m0s keepFiringFor: 0s labels: diff --git a/test/manual/fixtures/dashboard.yaml b/test/manual/fixtures/dashboard.yaml index 567b69d..a870f3d 100644 --- a/test/manual/fixtures/dashboard.yaml +++ b/test/manual/fixtures/dashboard.yaml @@ -1,14 +1,6 @@ kind: Dashboard metadata: - annotations: - dash0.com/source: ui - createdAt: "2026-02-13T17:54:17.121675493Z" - dash0Extensions: - dataset: default - id: 14e92422-c094-4cc6-a857-7d892044749e name: "" - updatedAt: "2026-02-13T17:54:17.121675493Z" - version: 8 spec: display: description: "" diff --git a/test/manual/fixtures/synthetic-check.yaml b/test/manual/fixtures/synthetic-check.yaml index d7a8ed2..db60a68 100644 --- a/test/manual/fixtures/synthetic-check.yaml +++ b/test/manual/fixtures/synthetic-check.yaml @@ -1,11 +1,5 @@ kind: Dash0SyntheticCheck metadata: - annotations: {} - description: "" - labels: - dash0.com/dataset: default - dash0.com/id: 64617368-3073-796e-7468-3be467e9fc91 - dash0.com/version: "1" name: new-synthetic-check spec: display: @@ -14,19 +8,6 @@ spec: labels: {} notifications: channels: [] - permissions: - - actions: - - synthetic_check:read - - synthetic_check:delete - - synthetic_check:write - role: admin - - actions: - - synthetic_check:read - role: basic_member - - actions: - - synthetic_check:write - - synthetic_check:delete - userId: a6dcbaaf-bc62-4aac-bafb-b036e06badc9 plugin: kind: http spec: diff --git a/test/manual/fixtures/view.yaml b/test/manual/fixtures/view.yaml index 56f59ec..f043055 100644 --- a/test/manual/fixtures/view.yaml +++ b/test/manual/fixtures/view.yaml @@ -1,11 +1,5 @@ kind: Dash0View metadata: - annotations: {} - labels: - dash0.com/dataset: default - dash0.com/id: fe520f3f-50f4-4e41-a2f3-ed916cf45e14 - dash0.com/source: userdefined - dash0.com/version: "7" name: minecraft-server spec: display: @@ -17,15 +11,6 @@ spec: value: minecraft groupBy: - otel.log.severity.range - permissions: - - actions: - - views:read - - views:delete - - views:write - role: admin - - actions: - - views:read - role: basic_member table: columns: - colSize: min-content diff --git a/test/manual/test_dashboard_roundtrip.sh b/test/manual/test_dashboard_roundtrip.sh index 559407a..e00fa49 100755 --- a/test/manual/test_dashboard_roundtrip.sh +++ b/test/manual/test_dashboard_roundtrip.sh @@ -33,7 +33,7 @@ if [ $? -ne 0 ]; then echo "FAIL: dashboards list -o json failed" exit 1 fi -ID=$(echo "$LIST_JSON" | jq -r --arg name "$ASSET_NAME" '[.[] | select(.name == $name)][0].id // empty') +ID=$(echo "$LIST_JSON" | jq -r --arg name "$ASSET_NAME" '[.[] | select(.spec.display.name == $name)][0].metadata.dash0Extensions.id // empty') if [ -z "$ID" ]; then echo "FAIL: Could not find created dashboard '$ASSET_NAME' in list" exit 1 @@ -76,7 +76,7 @@ if [ $? -ne 0 ]; then echo "FAIL: dashboards list -o json failed" exit 1 fi -if echo "$LIST_JSON" | jq -e --arg id "$ID" '.[] | select(.id == $id)' > /dev/null 2>&1; then +if echo "$LIST_JSON" | jq -e --arg id "$ID" '.[] | select(.metadata.dash0Extensions.id == $id)' > /dev/null 2>&1; then echo "FAIL: Dashboard '$ID' still exists after deletion" exit 1 fi diff --git a/test/manual/test_synthetic_check_roundtrip.sh b/test/manual/test_synthetic_check_roundtrip.sh index b67684d..adf33a8 100755 --- a/test/manual/test_synthetic_check_roundtrip.sh +++ b/test/manual/test_synthetic_check_roundtrip.sh @@ -8,7 +8,7 @@ FIXTURE="${FIXTURES}/synthetic-check.yaml" TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT -ASSET_NAME=$(yq '.metadata.name' "$FIXTURE") +ASSET_NAME=$(yq '.spec.display.name' "$FIXTURE") echo "=== Synthetic check round-trip test ===" echo "Asset name: $ASSET_NAME" @@ -33,7 +33,7 @@ if [ $? -ne 0 ]; then echo "FAIL: synthetic-checks list -o json failed" exit 1 fi -ID=$(echo "$LIST_JSON" | jq -r --arg name "$ASSET_NAME" '[.[] | select(.name == $name)][0].id // empty') +ID=$(echo "$LIST_JSON" | jq -r --arg name "$ASSET_NAME" '[.[] | select(.spec.display.name == $name)][0].metadata.labels["dash0.com/id"] // empty') if [ -z "$ID" ]; then echo "FAIL: Could not find created synthetic check '$ASSET_NAME' in list" exit 1 @@ -76,7 +76,7 @@ if [ $? -ne 0 ]; then echo "FAIL: synthetic-checks list -o json failed" exit 1 fi -if echo "$LIST_JSON" | jq -e --arg id "$ID" '.[] | select(.id == $id)' > /dev/null 2>&1; then +if echo "$LIST_JSON" | jq -e --arg id "$ID" '.[] | select(.metadata.labels["dash0.com/id"] == $id)' > /dev/null 2>&1; then echo "FAIL: Synthetic check '$ID' still exists after deletion" exit 1 fi diff --git a/test/manual/test_view_roundtrip.sh b/test/manual/test_view_roundtrip.sh index 0706281..4407e91 100755 --- a/test/manual/test_view_roundtrip.sh +++ b/test/manual/test_view_roundtrip.sh @@ -8,7 +8,7 @@ FIXTURE="${FIXTURES}/view.yaml" TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT -ASSET_NAME=$(yq '.metadata.name' "$FIXTURE") +ASSET_NAME=$(yq '.spec.display.name' "$FIXTURE") echo "=== View round-trip test ===" echo "Asset name: $ASSET_NAME" @@ -33,7 +33,7 @@ if [ $? -ne 0 ]; then echo "FAIL: views list -o json failed" exit 1 fi -ID=$(echo "$LIST_JSON" | jq -r --arg name "$ASSET_NAME" '[.[] | select(.name == $name)][0].id // empty') +ID=$(echo "$LIST_JSON" | jq -r --arg name "$ASSET_NAME" '[.[] | select(.spec.display.name == $name)][0].metadata.labels["dash0.com/id"] // empty') if [ -z "$ID" ]; then echo "FAIL: Could not find created view '$ASSET_NAME' in list" exit 1 @@ -76,7 +76,7 @@ if [ $? -ne 0 ]; then echo "FAIL: views list -o json failed" exit 1 fi -if echo "$LIST_JSON" | jq -e --arg id "$ID" '.[] | select(.id == $id)' > /dev/null 2>&1; then +if echo "$LIST_JSON" | jq -e --arg id "$ID" '.[] | select(.metadata.labels["dash0.com/id"] == $id)' > /dev/null 2>&1; then echo "FAIL: View '$ID' still exists after deletion" exit 1 fi