From 2eb4002dd4ffae52809df37ef385f156706f74b4 Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 17 Aug 2026 11:51:37 +0200 Subject: [PATCH 01/42] fix(asset): add missing KindDisplayName case for "team" KindDisplayName had no case for the normalized kind "team", so any caller relying on it for a team asset printed the raw lowercase "team" instead of a proper display name -- inconsistent with every other kind, whose display name is a distinct, human-readable string. --- internal/asset/kind.go | 2 ++ internal/asset/kind_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 internal/asset/kind_test.go diff --git a/internal/asset/kind.go b/internal/asset/kind.go index 21fd2cc3..b5630d89 100644 --- a/internal/asset/kind.go +++ b/internal/asset/kind.go @@ -31,6 +31,8 @@ func KindDisplayName(kind string) string { return "Notification channel" case "spamfilter": return "Spam filter" + case "team": + return "Team" default: return kind } diff --git a/internal/asset/kind_test.go b/internal/asset/kind_test.go new file mode 100644 index 00000000..42bb223e --- /dev/null +++ b/internal/asset/kind_test.go @@ -0,0 +1,35 @@ +package asset + +import "testing" + +// TestKindDisplayName_Team is a regression test for a bug where +// KindDisplayName had no case for "team", so any caller relying on this +// function for a team asset (e.g. the teams command group, or --since's +// deletion messages once it exists) printed the raw normalized kind string +// "team" instead of a proper display name. +func TestKindDisplayName_Team(t *testing.T) { + for _, kind := range []string{"team", "Team", "Dash0Team", "dash0-team"} { + if got := KindDisplayName(kind); got != "Team" { + t.Errorf("KindDisplayName(%q) = %q, want %q", kind, got, "Team") + } + } +} + +func TestKindDisplayName_KnownKinds(t *testing.T) { + cases := map[string]string{ + "Dashboard": "Dashboard", + "CheckRule": "Check rule", + "SyntheticCheck": "Synthetic check", + "View": "View", + "PrometheusRule": "PrometheusRule", + "PersesDashboard": "PersesDashboard", + "Dash0NotificationChannel": "Notification channel", + "Dash0SpamFilter": "Spam filter", + "Dash0Team": "Team", + } + for kind, want := range cases { + if got := KindDisplayName(kind); got != want { + t.Errorf("KindDisplayName(%q) = %q, want %q", kind, got, want) + } + } +} From 96ae5bc9d1c86eaf4a3f10c699848863135f9c22 Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 17 Aug 2026 08:22:35 +0200 Subject: [PATCH 02/42] feat(experimental): support gating a single flag, not just a whole command RequireExperimentalFlag lets a stable command keep one flag behind --experimental/-X, for cases where the flag itself needs more time to prove out before the rest of the command's behavior is committed to. --- internal/experimental/experimental.go | 23 ++++++++++++ internal/experimental/experimental_test.go | 43 ++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/internal/experimental/experimental.go b/internal/experimental/experimental.go index 88698e22..f798f251 100644 --- a/internal/experimental/experimental.go +++ b/internal/experimental/experimental.go @@ -26,3 +26,26 @@ func RequireExperimental(cmd *cobra.Command) error { } return nil } + +// RequireExperimentalFlag checks whether the --experimental (-X) flag has +// been set, for a single flag on an otherwise-stable command. Unlike +// RequireExperimental, it is a no-op — returning nil — when flagName was not +// passed at all, so the rest of the command remains fully stable and +// ungated. It only requires --experimental once the caller actually uses the +// experimental flag. +func RequireExperimentalFlag(cmd *cobra.Command, flagName string) error { + if !cmd.Flags().Changed(flagName) { + return nil + } + enabled, err := cmd.Flags().GetBool("experimental") + if err != nil { + enabled = false + } + if !enabled { + return fmt.Errorf( + "--%s is an experimental flag on %q; pass --experimental (or -X) to enable it", + flagName, cmd.CommandPath(), + ) + } + return nil +} diff --git a/internal/experimental/experimental_test.go b/internal/experimental/experimental_test.go index d51fc340..64ef1c58 100644 --- a/internal/experimental/experimental_test.go +++ b/internal/experimental/experimental_test.go @@ -49,3 +49,46 @@ func TestRequireExperimental_FlagNotRegistered(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "experimental command") } + +// newTestCmdWithSinceFlag builds a root/child pair like newTestCmd, plus a +// "since" string flag on the child — standing in for apply's --since flag. +func newTestCmdWithSinceFlag() (*cobra.Command, *cobra.Command) { + root, child := newTestCmd() + child.Flags().String("since", "", "since ref") + return root, child +} + +func TestRequireExperimentalFlag_NotPassed(t *testing.T) { + _, child := newTestCmdWithSinceFlag() + // Flag was never passed, so the gate is a no-op regardless of --experimental. + err := RequireExperimentalFlag(child, "since") + require.NoError(t, err) +} + +func TestRequireExperimentalFlag_PassedWithoutExperimental(t *testing.T) { + root, child := newTestCmdWithSinceFlag() + var childErr error + child.RunE = func(cmd *cobra.Command, args []string) error { + childErr = RequireExperimentalFlag(cmd, "since") + return childErr + } + root.SetArgs([]string{"child", "--since", "HEAD~1"}) + err := root.Execute() + require.Error(t, err) + require.Error(t, childErr) + assert.Contains(t, childErr.Error(), "--since") + assert.Contains(t, childErr.Error(), "--experimental") + assert.Contains(t, childErr.Error(), "-X") +} + +func TestRequireExperimentalFlag_PassedWithExperimental(t *testing.T) { + root, child := newTestCmdWithSinceFlag() + var childErr error + child.RunE = func(cmd *cobra.Command, args []string) error { + childErr = RequireExperimentalFlag(cmd, "since") + return childErr + } + root.SetArgs([]string{"--experimental", "child", "--since", "HEAD~1"}) + require.NoError(t, root.Execute()) + require.NoError(t, childErr) +} From 87c9d0e3d595d84eff2c53a46575a99f1a0baa2d Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 17 Aug 2026 08:23:14 +0200 Subject: [PATCH 03/42] refactor(asset): extract shared non-hidden-YAML-file discovery FindNonHiddenYAMLFiles centralizes the hidden-file/YAML-extension filtering apply's directory scan already did, as a fs.WalkDirFunc callers pass to their own filepath.WalkDir call. Kept as a walk-callback rather than a self-contained walker so the WalkDir invocation stays visible at each call site. --- docs/commands.md | 2 +- internal/apply/apply.go | 39 ++----- internal/apply/apply_test.go | 16 +++ internal/asset/discover.go | 74 ++++++++++++++ internal/asset/discover_test.go | 112 +++++++++++++++++++++ internal/skill/content/references/apply.md | 2 +- 6 files changed, 214 insertions(+), 31 deletions(-) create mode 100644 internal/asset/discover.go create mode 100644 internal/asset/discover_test.go diff --git a/docs/commands.md b/docs/commands.md index 98ee9d06..65a21913 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -883,7 +883,7 @@ For assets that are updated, a unified diff of the changes is shown. Assets that are created show the standard creation message. When a directory is specified, all `.yaml` and `.yml` files are discovered recursively. -Hidden files and directories (starting with `.`) are skipped. +Hidden files and directories (starting with `.`) are skipped, except for the directory passed to `-f` itself — a dot-prefixed target (e.g. `-f .dash0-assets/`) is a deliberate choice and is scanned normally. All documents are validated before any are applied. If any document fails validation, no changes are made. diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 89546171..8cfb4bfc 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -551,38 +551,19 @@ func readMultiDocumentYAML(filePath string, stdin io.Reader) ([]assetDocument, e // skipping hidden entries (names starting with '.'). // Returns paths relative to dirPath, sorted lexicographically. func discoverFiles(dirPath string) ([]string, error) { + var paths []string + var hasNestedDirs bool + if err := filepath.WalkDir(dirPath, asset.FindNonHiddenYAMLFiles(dirPath, &paths, &hasNestedDirs)); err != nil { + return nil, fmt.Errorf("failed to scan directory: %w", err) + } + var files []string - hasNestedDirs := false - err := filepath.WalkDir(dirPath, func(path string, d os.DirEntry, err error) error { + for _, path := range paths { + rel, err := filepath.Rel(dirPath, path) if err != nil { - return err - } - name := d.Name() - // Skip hidden files and directories - if strings.HasPrefix(name, ".") { - if d.IsDir() { - return filepath.SkipDir - } - return nil - } - if d.IsDir() { - if path != dirPath { - hasNestedDirs = true - } - return nil - } - ext := strings.ToLower(filepath.Ext(name)) - if ext == ".yaml" || ext == ".yml" { - rel, err := filepath.Rel(dirPath, path) - if err != nil { - return err - } - files = append(files, rel) + return nil, err } - return nil - }) - if err != nil { - return nil, fmt.Errorf("failed to scan directory: %w", err) + files = append(files, rel) } if len(files) == 0 { if hasNestedDirs { diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go index 3d1ad0a1..4f85d385 100644 --- a/internal/apply/apply_test.go +++ b/internal/apply/apply_test.go @@ -355,6 +355,22 @@ func TestDiscoverFiles_SkipsHidden(t *testing.T) { assert.Equal(t, []string{"visible.yaml"}, files) } +// TestDiscoverFiles_DotPrefixedTargetItselfIsNotHidden pins a deliberate +// behavior: a dot-prefixed directory explicitly passed via -f (e.g. +// -f .dash0-assets/) is a deliberate user choice, not something to skip — +// only path components *inside* it are checked against the hidden-name +// rule. +func TestDiscoverFiles_DotPrefixedTargetItselfIsNotHidden(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, ".dash0-assets") + require.NoError(t, os.MkdirAll(dir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "dashboard.yaml"), []byte("kind: Dashboard"), 0644)) + + files, err := discoverFiles(dir) + require.NoError(t, err) + assert.Equal(t, []string{"dashboard.yaml"}, files) +} + func TestDiscoverFiles_Sorted(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "z.yaml"), []byte("kind: Dashboard"), 0644)) diff --git a/internal/asset/discover.go b/internal/asset/discover.go new file mode 100644 index 00000000..86d87741 --- /dev/null +++ b/internal/asset/discover.go @@ -0,0 +1,74 @@ +package asset + +import ( + "io/fs" + "path/filepath" + "strings" +) + +// IsYAMLFile reports whether path has a .yaml or .yml extension +// (case-insensitive) — the file types apply's directory scan and --since's +// git-ref/disk scans consider. +func IsYAMLFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return ext == ".yaml" || ext == ".yml" +} + +// IsHiddenPath reports whether any slash-separated component of path starts +// with "." — used to skip hidden files and directories consistently across +// a directory walk (a single entry name), a git ls-tree listing (a full +// repo-relative path), and any other path-filtering scan. +func IsHiddenPath(path string) bool { + for part := range strings.SplitSeq(path, "/") { + if strings.HasPrefix(part, ".") { + return true + } + } + return false +} + +// FindNonHiddenYAMLFiles returns a fs.WalkDirFunc for passing directly to +// filepath.WalkDir(root, ...): it appends every non-hidden .yaml/.yml file +// visited to *files and skips hidden files and directories (any path +// component starting with "."), via fs.SkipDir for directories. This is the +// one walk callback shared by apply's directory-scan and --since's +// disk-side scan, so both agree on what counts as an asset-definition file +// without hand-rolling the same callback twice; the filepath.WalkDir call +// itself stays at each call site for readability. +// +// root itself is exempt from the hidden-name check even if it starts with +// "." — an -f target the user named explicitly (e.g. -f .dash0-assets/) is +// a deliberate choice, not something to skip. Only path components *inside* +// root are checked. ListYAMLFilesAtRef (internal/git/plumbing.go) mirrors +// this same exemption for --since's git-ref-side scan, so a dot-prefixed +// -f target means the same thing on both sides of the diff. +// +// sawNestedDir, if non-nil, is set to true the first time the walk visits a +// non-hidden subdirectory of root — letting a caller tailor a "nothing +// found" message (e.g. "in and nested directories" vs a flat "in +// "). Pass nil when the caller doesn't need this. +func FindNonHiddenYAMLFiles(root string, files *[]string, sawNestedDir *bool) fs.WalkDirFunc { + return func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + name := d.Name() + if d.IsDir() { + if path == root { + return nil + } + if IsHiddenPath(name) { + return filepath.SkipDir + } + if sawNestedDir != nil { + *sawNestedDir = true + } + return nil + } + if IsHiddenPath(name) || !IsYAMLFile(name) { + return nil + } + *files = append(*files, path) + return nil + } +} diff --git a/internal/asset/discover_test.go b/internal/asset/discover_test.go new file mode 100644 index 00000000..712172e6 --- /dev/null +++ b/internal/asset/discover_test.go @@ -0,0 +1,112 @@ +package asset + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsYAMLFile(t *testing.T) { + assert.True(t, IsYAMLFile("dashboard.yaml")) + assert.True(t, IsYAMLFile("dashboard.yml")) + assert.True(t, IsYAMLFile("DASHBOARD.YAML")) + assert.True(t, IsYAMLFile("nested/dir/dashboard.yaml")) + assert.False(t, IsYAMLFile("dashboard.json")) + assert.False(t, IsYAMLFile("dashboard.txt")) + assert.False(t, IsYAMLFile("dashboard")) +} + +func TestIsHiddenPath(t *testing.T) { + assert.True(t, IsHiddenPath(".hidden")) + assert.True(t, IsHiddenPath(".hidden/dashboard.yaml")) + assert.True(t, IsHiddenPath("dir/.hidden/dashboard.yaml")) + assert.True(t, IsHiddenPath("dir/.hidden")) + assert.False(t, IsHiddenPath("dashboard.yaml")) + assert.False(t, IsHiddenPath("dir/dashboard.yaml")) + assert.False(t, IsHiddenPath("")) +} + +func writeTestFile(t *testing.T, dir, relPath, content string) { + t.Helper() + full := filepath.Join(dir, relPath) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(content), 0o644)) +} + +func TestFindNonHiddenYAMLFiles_FlatDirectory(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "a.yaml", "kind: Dashboard\n") + writeTestFile(t, dir, "b.yml", "kind: View\n") + writeTestFile(t, dir, "notes.txt", "not yaml") + + var files []string + var sawNestedDir bool + require.NoError(t, filepath.WalkDir(dir, FindNonHiddenYAMLFiles(dir, &files, &sawNestedDir))) + assert.False(t, sawNestedDir) + assert.ElementsMatch(t, []string{filepath.Join(dir, "a.yaml"), filepath.Join(dir, "b.yml")}, files) +} + +func TestFindNonHiddenYAMLFiles_NestedDirs(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "sub/nested.yaml", "kind: Dashboard\n") + + var files []string + var sawNestedDir bool + require.NoError(t, filepath.WalkDir(dir, FindNonHiddenYAMLFiles(dir, &files, &sawNestedDir))) + assert.True(t, sawNestedDir) + assert.Equal(t, []string{filepath.Join(dir, "sub/nested.yaml")}, files) +} + +func TestFindNonHiddenYAMLFiles_SkipsHiddenFilesAndDirs(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "visible.yaml", "kind: Dashboard\n") + writeTestFile(t, dir, ".hidden.yaml", "kind: Dashboard\n") + writeTestFile(t, dir, ".hidden/inside.yaml", "kind: Dashboard\n") + + var files []string + var sawNestedDir bool + require.NoError(t, filepath.WalkDir(dir, FindNonHiddenYAMLFiles(dir, &files, &sawNestedDir))) + assert.False(t, sawNestedDir, "the only subdirectory is hidden, so it must not count as a nested dir") + assert.Equal(t, []string{filepath.Join(dir, "visible.yaml")}, files) +} + +func TestFindNonHiddenYAMLFiles_EmptyDir(t *testing.T) { + dir := t.TempDir() + + var files []string + var sawNestedDir bool + require.NoError(t, filepath.WalkDir(dir, FindNonHiddenYAMLFiles(dir, &files, &sawNestedDir))) + assert.False(t, sawNestedDir) + assert.Empty(t, files) +} + +func TestFindNonHiddenYAMLFiles_NilSawNestedDir(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "sub/nested.yaml", "kind: Dashboard\n") + + var files []string + require.NoError(t, filepath.WalkDir(dir, FindNonHiddenYAMLFiles(dir, &files, nil))) + assert.Equal(t, []string{filepath.Join(dir, "sub/nested.yaml")}, files) +} + +// TestFindNonHiddenYAMLFiles_DotPrefixedRootIsNotHidden pins a deliberate +// behavior: a dot-prefixed root (e.g. -f .dash0-assets/, explicitly named by +// the user) is exempt from the hidden-name check, even though every +// component of an ordinary path is otherwise checked. Only the root itself +// is exempt — a hidden entry *within* it is still skipped. This must stay in +// sync with ListYAMLFilesAtRef's equivalent git-ref-side exemption +// (internal/git/plumbing.go), which mirrors this rule so the two scan sides +// agree on what a dot-prefixed -f target means. +func TestFindNonHiddenYAMLFiles_DotPrefixedRootIsNotHidden(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, ".dash0-assets") + writeTestFile(t, dir, "dashboard.yaml", "kind: Dashboard\n") + writeTestFile(t, dir, ".hidden/inside.yaml", "kind: Dashboard\n") + + var files []string + require.NoError(t, filepath.WalkDir(dir, FindNonHiddenYAMLFiles(dir, &files, nil))) + assert.Equal(t, []string{filepath.Join(dir, "dashboard.yaml")}, files, "the dot-prefixed root itself must not be treated as hidden, but a hidden directory nested inside it still must be skipped") +} diff --git a/internal/skill/content/references/apply.md b/internal/skill/content/references/apply.md index 3603a5e3..17c21645 100644 --- a/internal/skill/content/references/apply.md +++ b/internal/skill/content/references/apply.md @@ -17,7 +17,7 @@ For assets that are updated, a unified diff of the changes is shown. Assets that are created show the standard creation message. When a directory is specified, all `.yaml` and `.yml` files are discovered recursively. -Hidden files and directories (starting with `.`) are skipped. +Hidden files and directories (starting with `.`) are skipped, except for the directory passed to `-f` itself — a dot-prefixed target (e.g. `-f .dash0-assets/`) is a deliberate choice and is scanned normally. All documents are validated before any are applied. If any document fails validation, no changes are made. From 516445d8ad48c2d453b0592bb37187074e428f32 Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 17 Aug 2026 08:23:49 +0200 Subject: [PATCH 04/42] feat(apply): add --since/--force for git-history-based deletion sync Adds internal/git, a thin wrapper over git plumbing (rev-parse, merge-base, cat-file, ls-tree) that classifies a --since ref (empty, all-zeros sentinel, resolved ancestor, resolved non-ancestor, unresolvable) and diffs asset identifiers between that ref and the current -f contents to compute a deletion plan -- by identity (id/origin), never by file path, including PrometheusRule's CRD-shared-identifier / per-alert-name distinction. apply --since wires this into the existing create/update flow: ref-resolution errors, a confirmation prompt for a non-ancestor ref (bypassable with the new --force), per-asset deletion confirmation, and a non-zero exit when a deletion is declined. Both are gated behind --experimental/-X for now (--since via the new RequireExperimentalFlag), pending real-world testing before promotion. Includes the openspec proposal/design/specs/tasks for this change and the follow-on asset-synch GitHub Action, and a go.mod replace pointing at a local dash0-api-client-go checkout for the identifier-extraction helpers (ExtractIdentifier/ExtractPrometheusAlertNames) this depends on, pending their release. --- docs/commands.md | 2 +- go.mod | 4 + internal/apply/apply.go | 83 +- internal/apply/since.go | 340 +++++++ internal/apply/since_integration_test.go | 959 ++++++++++++++++++ internal/apply/since_test.go | 263 +++++ internal/asset/kind.go | 29 +- internal/asset/prometheusrule.go | 108 +- internal/asset/prometheusrule_test.go | 118 +++ internal/asset/spamfilter.go | 22 + internal/asset/spamfilter_test.go | 63 ++ internal/git/diff.go | 154 +++ internal/git/diff_test.go | 213 ++++ internal/git/plumbing.go | 159 +++ internal/git/plumbing_test.go | 136 +++ internal/git/ref.go | 69 ++ internal/git/ref_test.go | 81 ++ internal/git/snapshot.go | 285 ++++++ internal/git/snapshot_test.go | 189 ++++ internal/git/testrepo_test.go | 59 ++ .../changes/add-asset-synch-action/design.md | 45 + .../add-asset-synch-action/proposal.md | 21 + .../specs/github-actions/spec.md | 51 + .../changes/add-asset-synch-action/tasks.md | 32 + .../changes/add-diff-and-since-flag/design.md | 107 ++ .../add-diff-and-since-flag/proposal.md | 30 + .../specs/apply/spec.md | 172 ++++ .../specs/diff/spec.md | 87 ++ .../changes/add-diff-and-since-flag/tasks.md | 119 +++ 29 files changed, 3974 insertions(+), 26 deletions(-) create mode 100644 internal/apply/since.go create mode 100644 internal/apply/since_integration_test.go create mode 100644 internal/apply/since_test.go create mode 100644 internal/asset/spamfilter_test.go create mode 100644 internal/git/diff.go create mode 100644 internal/git/diff_test.go create mode 100644 internal/git/plumbing.go create mode 100644 internal/git/plumbing_test.go create mode 100644 internal/git/ref.go create mode 100644 internal/git/ref_test.go create mode 100644 internal/git/snapshot.go create mode 100644 internal/git/snapshot_test.go create mode 100644 internal/git/testrepo_test.go create mode 100644 openspec/changes/add-asset-synch-action/design.md create mode 100644 openspec/changes/add-asset-synch-action/proposal.md create mode 100644 openspec/changes/add-asset-synch-action/specs/github-actions/spec.md create mode 100644 openspec/changes/add-asset-synch-action/tasks.md create mode 100644 openspec/changes/add-diff-and-since-flag/design.md create mode 100644 openspec/changes/add-diff-and-since-flag/proposal.md create mode 100644 openspec/changes/add-diff-and-since-flag/specs/apply/spec.md create mode 100644 openspec/changes/add-diff-and-since-flag/specs/diff/spec.md create mode 100644 openspec/changes/add-diff-and-since-flag/tasks.md diff --git a/docs/commands.md b/docs/commands.md index 65a21913..625a11c0 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -730,7 +730,7 @@ The identifier field location varies by asset kind: | `PrometheusRule` (recording rules) | `metadata.labels["dash0.com/id"]` | | | `SyntheticCheck` | `metadata.labels["dash0.com/id"]` | | | `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 | +| `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. This also affects `apply --since`: since the id recorded in git history may no longer match the live filter's reassigned id, deleting an ID-only spam filter by that stale id can miss the real live filter — `apply --since` prints a warning before deleting an ID-only spam filter for this reason. Use `dash0.com/origin` for spam filters you intend to manage with `--since` | | `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 | | `Dash0Team` | `metadata.labels["dash0.com/origin"]` (`metadata.labels["dash0.com/id"]` when origin is absent) | Organization-level. `dash0.com/origin` is preferred and upserts by that origin (PUT). When only `dash0.com/id` is present the CLI preflights `GET /api/teams/{id}`: on hit it PUTs (idempotent update — this is what makes reapplying a YAML downloaded from the Dash0 platform UI a no-op), on 404 it falls back to POST so cross-org apply stays idempotent, and other errors surface. A document with neither label creates a new team on every apply. `spec.members` accepts email addresses or internal member ids interchangeably | diff --git a/go.mod b/go.mod index b144de69..ac0e3706 100644 --- a/go.mod +++ b/go.mod @@ -98,3 +98,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/protobuf v1.36.12 // indirect ) + +// Local development against an unreleased dash0-api-client-go change. +// Remove once the change is released and go.mod is bumped to that version. +replace github.com/dash0hq/dash0-api-client-go => ../dash0-api-client-go diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 8cfb4bfc..939cb964 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -15,6 +15,8 @@ import ( "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/dash0hq/dash0-cli/internal/experimental" "github.com/spf13/cobra" "gopkg.in/yaml.v3" sigsyaml "sigs.k8s.io/yaml" @@ -27,6 +29,17 @@ type applyFlags struct { Dataset string File string DryRun bool + Since string + // SinceFlagSet records whether --since was actually passed on the + // command line, as opposed to left at its "" zero value. This is + // distinct from Since != "": a CI script building + // --since="${{ github.event.before }}" can pass an explicitly empty + // string (e.g. on a workflow_dispatch/schedule trigger with no prior + // ref), and that case must still route through computeDeletionPlan to + // hit the dedicated RefEmpty error, not be silently treated the same as + // --since never being mentioned at all. + SinceFlagSet bool + Force bool } // NewApplyCmd creates the top-level apply command @@ -54,7 +67,9 @@ Supported asset types: A PrometheusRule CRD that mixes alerting and recording rules is dispatched to both endpoints; alerting rules become check rules and recording rules become a recording rule. -If an asset exists, it will be updated. If it doesn't exist, it will be created.` + internal.CONFIG_HINT, +If an asset exists, it will be updated. If it doesn't exist, it will be created. + +[experimental] Pass --since (requires --experimental/-X) to also delete assets whose definition existed at but is no longer present in -f's current contents, detected by identifier (id or origin), never by file path. --force skips the per-deletion confirmation prompt.` + internal.CONFIG_HINT, Example: ` # Apply a single asset dash0 apply -f dashboard.yaml @@ -71,7 +86,13 @@ If an asset exists, it will be updated. If it doesn't exist, it will be created. dash0 apply -f assets.yaml --dry-run # Validate a directory without applying - dash0 apply -f dashboards/ --dry-run`, + dash0 apply -f dashboards/ --dry-run + + # Sync a directory to match its state as of a git ref, deleting assets removed since then (experimental) + dash0 --experimental apply -f dashboards/ --since HEAD~1 + + # Same, without the per-deletion confirmation prompt (experimental) + dash0 --experimental apply -f dashboards/ --since HEAD~1 --force`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { return fmt.Errorf("unexpected arguments: %s\nTo apply multiple files, pass a directory with -f instead of a glob pattern", strings.Join(args, " ")) @@ -79,6 +100,13 @@ If an asset exists, it will be updated. If it doesn't exist, it will be created. if flags.File == "" { return fmt.Errorf("file is required; use -f to specify the file (use '-' for stdin)") } + if err := experimental.RequireExperimentalFlag(cmd, "since"); err != nil { + return err + } + flags.SinceFlagSet = cmd.Flags().Changed("since") + if flags.SinceFlagSet && flags.File == "-" { + return fmt.Errorf("--since '%s' cannot be used with -f - (stdin); it needs a file or directory path to compare against git history", flags.Since) + } cmd.SilenceUsage = true return runApply(cmd.Context(), &flags) }, @@ -89,6 +117,8 @@ If an asset exists, it will be updated. If it doesn't exist, it will be created. cmd.Flags().StringVar(&flags.ApiUrl, "api-url", "", "API URL for the Dash0 API (overrides active profile)") cmd.Flags().StringVar(&flags.AuthToken, "auth-token", "", "Auth token for the Dash0 API (overrides active profile)") cmd.Flags().StringVar(&flags.Dataset, "dataset", "", "Dataset to operate on") + cmd.Flags().StringVar(&flags.Since, "since", "", "[experimental] Delete assets removed from -f's contents since this git ref (requires --experimental/-X)") + cmd.Flags().BoolVar(&flags.Force, "force", false, "Skip the confirmation prompt for deletions triggered by --since") return cmd } @@ -175,8 +205,23 @@ func runApply(ctx context.Context, flags *applyFlags) error { fmt.Fprintf(os.Stderr, "warning: %s\n", warning) } + var deletionPlan *deletionPlan + if flags.SinceFlagSet { + plan, err := computeDeletionPlan(ctx, flags) + if err != nil { + return err + } + deletionPlan = plan + } + if flags.DryRun { - return printDryRun(documents, fromDirectory) + if err := printDryRun(documents, fromDirectory); err != nil { + return err + } + if deletionPlan != nil { + printDeletionPreview(deletionPlan) + } + return nil } // Create API client @@ -217,6 +262,31 @@ func runApply(ctx context.Context, flags *applyFlags) error { } } + if deletionPlan != nil { + // The non-ancestor confirmation happens here, after every document + // create/update above has already gone through — never before them. + // Gating it earlier (inside computeDeletionPlan, as this used to + // work) meant a declined or unconfirmable --since ref aborted the + // entire apply run, including ordinary creates/updates that have + // nothing to do with --since's ancestry check. + if deletionPlan.warning != "" { + fmt.Fprintf(os.Stderr, "warning: %s\n", deletionPlan.warning) + confirmed, confirmErr := confirmation.ConfirmDestructiveOperation(ctx, "Continue with --since's deletions? [y/N]: ", flags.Force) + if confirmErr != nil || !confirmed { + skipped := len(deletionPlan.plan.ByIdentifier) + len(deletionPlan.plan.AlertsByName) + fmt.Fprintf(os.Stderr, "--since's deletion phase skipped; the rest of the run already completed\n") + return fmt.Errorf("%s not confirmed for deletion (--since ref is not an ancestor of HEAD)", pluralize(skipped, "asset")) + } + } + declined, err := applyDeletions(ctx, apiClient, dataset, deletionPlan, flags.Force) + if err != nil { + return err + } + if declined > 0 { + return fmt.Errorf("%s declined; the rest of the --since run completed", pluralize(declined, "deletion")) + } + } + return nil } @@ -599,12 +669,7 @@ func readDirectory(dirPath string) ([]assetDocument, error) { } func isValidKind(kind string) bool { - switch normalizeKind(kind) { - case "dashboard", "checkrule", "syntheticcheck", "view", "prometheusrule", "persesdashboard", "spamfilter", "notificationchannel", "team": - return true - default: - return false - } + return asset.IsValidKind(kind) } func normalizeKind(kind string) string { diff --git a/internal/apply/since.go b/internal/apply/since.go new file mode 100644 index 00000000..09c3662f --- /dev/null +++ b/internal/apply/since.go @@ -0,0 +1,340 @@ +package apply + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + dash0api "github.com/dash0hq/dash0-api-client-go" + "github.com/dash0hq/dash0-cli/internal/asset" + "github.com/dash0hq/dash0-cli/internal/client" + "github.com/dash0hq/dash0-cli/internal/confirmation" + gitutil "github.com/dash0hq/dash0-cli/internal/git" +) + +// deletionPlan wraps the identifier-diffing result from internal/git with a +// human-readable warning to surface when --since's ref resolved but is not +// an ancestor of HEAD. +type deletionPlan struct { + plan gitutil.DeletionPlan + warning string +} + +// computeDeletionPlan resolves flags.Since against the git repository +// containing flags.File and diffs the identifier set at that ref against +// flags.File's current disk contents. It never talks to the Dash0 API — +// everything it needs comes from git and the local filesystem. +func computeDeletionPlan(ctx context.Context, flags *applyFlags) (*deletionPlan, error) { + absFile, err := filepath.Abs(flags.File) + if err != nil { + return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", flags.File, err) + } + // Resolve symlinks so absFile is comparable with repo.Root()'s output: + // `git rev-parse --show-toplevel` always prints the fully-resolved real + // path, but filepath.Abs alone does not resolve symlinks in parent + // directories (e.g. macOS's /var -> /private/var), which would otherwise + // make every filepath.Rel(repoRoot, absFile) below compute a bogus + // "outside the repository" path. + absFile, err = filepath.EvalSymlinks(absFile) + if err != nil { + return nil, fmt.Errorf("failed to resolve %s: %w", flags.File, err) + } + + info, err := os.Stat(absFile) + if err != nil { + return nil, fmt.Errorf("failed to stat %s: %w", flags.File, err) + } + repoDir := absFile + if !info.IsDir() { + repoDir = filepath.Dir(absFile) + } + repo := gitutil.Repo{Dir: repoDir} + + repoRoot, err := repo.Root(ctx) + if err != nil { + return nil, fmt.Errorf("--since '%s' requires %s to be inside a git repository: %w", flags.Since, flags.File, err) + } + // Re-anchor at repoRoot: every scope-relative pathspec built below (and + // passed to BuildSnapshotFromRef) is repo-root-relative, matching git + // ls-tree's own path convention. Running git commands with -C repoDir + // when repoDir is a subdirectory of the repo would otherwise resolve + // those pathspecs relative to repoDir instead, silently matching nothing + // (e.g. -f dashboards/ turning scope "dashboards" into the nonexistent + // "dashboards/dashboards" once -C is already inside dashboards/). + repo = gitutil.Repo{Dir: repoRoot} + + refState, sha, err := repo.ClassifyRef(ctx, flags.Since) + if err != nil { + return nil, fmt.Errorf("failed to resolve --since '%s' as Git reference: %w", flags.Since, err) + } + + var warning string + switch refState { + case gitutil.RefEmpty: + return nil, fmt.Errorf("--since '%s' resolved to an empty ref; there is no prior state to compare against (this can happen when a CI-provided ref variable is unset — check the workflow's before/after ref inputs)", flags.Since) + case gitutil.RefAllZeros: + return nil, fmt.Errorf("--since '%s' resolved to git's all-zeros SHA (%s), meaning there is no prior state to compare against (some CI systems report this value for a ref's first push)", flags.Since, gitutil.AllZerosSHA) + case gitutil.RefUnresolvable: + return nil, fmt.Errorf("--since '%s' could not be resolved (check for a typo, or a too-shallow clone missing the needed history)", flags.Since) + case gitutil.RefResolvedNonAncestor: + // The confirmation for this case is deliberately NOT done here: doing + // so would abort the entire apply run (including ordinary, unrelated + // creates/updates) before any document is even processed, just + // because the --since ref needs confirming. Instead, runApply asks + // for confirmation immediately before calling applyDeletions, after + // every document create/update has already gone through — mirroring + // how a declined per-asset deletion (applyDeletions itself) never + // blocks the rest of the run, just the deletions. + warning = fmt.Sprintf("--since '%s' is not an ancestor of HEAD (likely a force-push or history rewrite); deletion detection may be inaccurate", flags.Since) + } + + scope, err := filepath.Rel(repoRoot, absFile) + if err != nil { + return nil, fmt.Errorf("failed to compute %s's path relative to repository root %s: %w", flags.File, repoRoot, err) + } + scope = filepath.ToSlash(scope) + if scope == "." { + scope = "" + } + + before, err := gitutil.BuildSnapshotFromRef(ctx, repo, sha, scope) + if err != nil { + return nil, fmt.Errorf("failed to read git state at --since ref '%s': %w", flags.Since, err) + } + after, err := gitutil.BuildSnapshotFromDisk(ctx, absFile, repoRoot) + if err != nil { + return nil, fmt.Errorf("failed to read current Git state: %w", err) + } + + plan := gitutil.Diff(before, after) + if len(plan.NoIdentifier) > 0 { + return nil, fmt.Errorf("--since '%s' found %s deleted with no dash0.com/id or dash0.com/origin label, so deletion cannot be determined reliably:\n %s", + flags.Since, pluralize(len(plan.NoIdentifier), "document"), strings.Join(plan.NoIdentifier, "\n ")) + } + + return &deletionPlan{plan: plan, warning: warning}, nil +} + +func printDeletionPreview(dp *deletionPlan) { + if dp.warning != "" { + fmt.Fprintf(os.Stderr, "warning: %s\n", dp.warning) + } + if dp.plan.IsEmpty() { + fmt.Println("--since: no deletions") + return + } + fmt.Println("--since would delete:") + for _, d := range dp.plan.ByIdentifier { + fmt.Printf(" - %s (%s)\n", asset.KindDisplayName(d.Kind), d.Identifier) + } + for _, a := range dp.plan.AlertsByName { + fmt.Printf(" - Check rule %q (alert removed from PrometheusRule %s)\n", a.CheckRuleName(), a.CRDIdentifier) + } +} + +// applyDeletions carries out dp's deletion plan against the Dash0 API, +// prompting per asset (skipped when force is set) exactly like every +// standalone ` delete --force`. It returns the number of deletions the +// caller declined so runApply can report a non-zero exit even though the +// rest of the run succeeded. +// +// dp.warning (set when --since's ref is a non-ancestor) is not printed here: +// runApply already surfaced it once, as part of confirming whether to run +// the deletion phase at all, before calling this function. Printing it again +// here would show the identical line to the user twice for no reason. +func applyDeletions(ctx context.Context, apiClient dash0api.Client, dataset *string, dp *deletionPlan, force bool) (int, error) { + declined := 0 + + for _, d := range dp.plan.ByIdentifier { + displayKind := asset.KindDisplayName(d.Kind) + if d.Kind == "spamfilter" && !d.SpamFilterUsesOrigin { + fmt.Fprintf(os.Stderr, "warning: spam filter %q was identified by dash0.com/id alone; its live id may have been reassigned by the server since this identifier was recorded (see docs/commands.md's asset-identifiers section), so this delete may miss the actual live filter\n", d.Identifier) + } + prompt := fmt.Sprintf("Are you sure you want to delete %s %q, removed since --since ref? [y/N]: ", displayKind, d.Identifier) + confirmed, err := confirmation.ConfirmDestructiveOperation(ctx, prompt, force) + if err != nil { + return declined, err + } + if !confirmed { + fmt.Fprintf(os.Stderr, "%s %q: deletion declined\n", displayKind, d.Identifier) + declined++ + continue + } + if err := deleteAssetByKindAndIdentifier(ctx, apiClient, dataset, d, force); err != nil { + return declined, fmt.Errorf("failed to delete %s %q: %w", displayKind, d.Identifier, err) + } + fmt.Printf("%s %q deleted\n", displayKind, d.Identifier) + } + + for _, a := range dp.plan.AlertsByName { + name := a.CheckRuleName() + prompt := fmt.Sprintf("Are you sure you want to delete check rule %q, an alert removed from a PrometheusRule since --since ref? [y/N]: ", name) + confirmed, err := confirmation.ConfirmDestructiveOperation(ctx, prompt, force) + if err != nil { + return declined, err + } + if !confirmed { + fmt.Fprintf(os.Stderr, "Check rule %q: deletion declined\n", name) + declined++ + continue + } + if err := deleteCheckRuleByName(ctx, apiClient, dataset, name, force); err != nil { + return declined, fmt.Errorf("failed to delete check rule %q: %w", name, err) + } + fmt.Printf("Check rule %q deleted\n", name) + } + + return declined, nil +} + +// deleteAssetByKindAndIdentifier dispatches a whole-asset deletion (an +// asset whose identifier disappeared entirely) to the matching per-kind +// delete API call, mirroring the dispatch applyDocument already uses for +// create/update. +func deleteAssetByKindAndIdentifier(ctx context.Context, apiClient dash0api.Client, dataset *string, d gitutil.Deletion, force bool) error { + kind, identifier := d.Kind, d.Identifier + if kind == "prometheusrule" { + return deletePrometheusRuleCRD(ctx, apiClient, dataset, identifier, d.PrometheusRuleEndpoints, force) + } + + var err error + switch kind { + case "dashboard", "persesdashboard": + err = apiClient.DeleteDashboard(ctx, identifier, dataset) + case "checkrule": + err = apiClient.DeleteCheckRule(ctx, identifier, dataset) + case "syntheticcheck": + err = apiClient.DeleteSyntheticCheck(ctx, identifier, dataset) + case "view": + err = apiClient.DeleteView(ctx, identifier, dataset) + case "spamfilter": + err = apiClient.DeleteSpamFilter(ctx, identifier, dataset) + case "notificationchannel": + err = apiClient.DeleteNotificationChannel(ctx, identifier) + case "team": + err = apiClient.DeleteTeam(ctx, identifier) + default: + return fmt.Errorf("unsupported kind for deletion: %s", kind) + } + + ectx := client.ErrorContext{AssetType: asset.KindDisplayName(kind), AssetID: identifier} + if err != nil { + if client.IsAlreadyDeleted(err, force, ectx) { + return nil + } + return client.HandleAPIError(err, ectx) + } + return nil +} + +// deletePrometheusRuleCRD deletes a whole PrometheusRule CRD by identifier. +// The same identifier may back a check rule (from the CRD's alerting rules), +// a recording rule (from its recording rules), or both — apply's own +// create/update dispatch (applyPrometheusRule) sends a mixed CRD to both +// endpoints, so a mixed CRD's deletion attempts both too. +// +// endpoints (extracted from the CRD's content at --since's ref, before it +// was deleted) says which endpoint(s) the CRD actually used. Only those are +// called: unconditionally attempting both and tolerating a 404 from +// whichever wasn't used would silently delete an unrelated asset that +// happens to carry the same identifier on the endpoint this CRD never used. +// If endpoints reports neither (only possible for a Snapshot built before +// this field existed, or corrupted git history), both are attempted and a +// 404 from either is tolerated, matching the old best-effort behavior. +func deletePrometheusRuleCRD(ctx context.Context, apiClient dash0api.Client, dataset *string, identifier string, endpoints gitutil.PrometheusRuleEndpoints, force bool) error { + tryCheckRule := endpoints.HasAlerts + tryRecordingRule := endpoints.HasRecords + if !tryCheckRule && !tryRecordingRule { + tryCheckRule, tryRecordingRule = true, true + } + + var checkRuleErr, recordingRuleErr error + if tryCheckRule { + checkRuleErr = apiClient.DeleteCheckRule(ctx, identifier, dataset) + } + if tryRecordingRule { + recordingRuleErr = apiClient.DeleteRecordingRule(ctx, identifier, dataset) + } + + checkRuleNotFound := checkRuleErr != nil && dash0api.IsNotFound(checkRuleErr) + recordingRuleNotFound := recordingRuleErr != nil && dash0api.IsNotFound(recordingRuleErr) + + if checkRuleErr != nil && !checkRuleNotFound { + ectx := client.ErrorContext{AssetType: "check rule", AssetID: identifier} + if client.IsAlreadyDeleted(checkRuleErr, force, ectx) { + return nil + } + return client.HandleAPIError(checkRuleErr, ectx) + } + if recordingRuleErr != nil && !recordingRuleNotFound { + ectx := client.ErrorContext{AssetType: "recording rule", AssetID: identifier} + if client.IsAlreadyDeleted(recordingRuleErr, force, ectx) { + return nil + } + return client.HandleAPIError(recordingRuleErr, ectx) + } + + // "Genuinely gone" means 404 on every endpoint that was actually tried — + // an endpoint that was never tried (because the CRD didn't use it) + // contributes no signal either way. + genuinelyGone := (!tryCheckRule || checkRuleNotFound) && (!tryRecordingRule || recordingRuleNotFound) + if genuinelyGone { + ectx := client.ErrorContext{AssetType: "PrometheusRule", AssetID: identifier} + firstErr := checkRuleErr + if firstErr == nil { + firstErr = recordingRuleErr + } + if client.IsAlreadyDeleted(firstErr, force, ectx) { + return nil + } + return client.HandleAPIError(firstErr, ectx) + } + return nil +} + +// deleteCheckRuleByName resolves a check rule by its exact name (the " - " composed by apply's create/update path) and deletes +// it. This is the only way to target a single alerting rule removed from a +// PrometheusRule CRD that otherwise survives: the CRD's shared identifier +// can't distinguish between the alerts it contains. +func deleteCheckRuleByName(ctx context.Context, apiClient dash0api.Client, dataset *string, name string, force bool) error { + id, err := findCheckRuleIDByName(ctx, apiClient, dataset, name) + if err != nil { + return err + } + if id == "" { + if force { + fmt.Fprintf(os.Stderr, "Check rule %q was already deleted\n", name) + return nil + } + return fmt.Errorf("check rule %q not found (already deleted?)", name) + } + + err = apiClient.DeleteCheckRule(ctx, id, dataset) + ectx := client.ErrorContext{AssetType: "check rule", AssetID: id, AssetName: name} + if err != nil { + if client.IsAlreadyDeleted(err, force, ectx) { + return nil + } + return client.HandleAPIError(err, ectx) + } + return nil +} + +// findCheckRuleIDByName lists every check rule in dataset and returns the ID +// of the first one whose name matches exactly, or "" if none matches. +func findCheckRuleIDByName(ctx context.Context, apiClient dash0api.Client, dataset *string, name string) (string, error) { + iter := apiClient.ListCheckRulesIter(ctx, dataset) + for iter.Next() { + item := iter.Current() + if item.Name != nil && *item.Name == name { + return item.Id, nil + } + } + if err := iter.Err(); err != nil { + return "", fmt.Errorf("failed to list check rules: %w", err) + } + return "", nil +} diff --git a/internal/apply/since_integration_test.go b/internal/apply/since_integration_test.go new file mode 100644 index 00000000..2ed3d41f --- /dev/null +++ b/internal/apply/since_integration_test.go @@ -0,0 +1,959 @@ +//go:build integration + +package apply + +import ( + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/dash0hq/dash0-cli/internal/confirmation" + "github.com/dash0hq/dash0-cli/internal/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newSinceTestCmd() *cobra.Command { + cmd := NewApplyCmd() + withExperimentalFlag(cmd) + return cmd +} + +func TestApply_Since_WholeFileDeletion(t *testing.T) { + testutil.SetupTestEnv(t) + + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "dashboard.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add dashboard") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(dir, "dashboard.yaml"))) + writeFileFixture(t, dir, "keep.yaml", "apiVersion: dash0.com/v1alpha1\nkind: View\nmetadata:\n name: keep\n labels:\n dash0.com/id: keep-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove dashboard") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureViewsNotFound, + }) + server.WithViewsUpdate(testutil.FixtureViewsImportSuccess) + server.OnPattern(http.MethodDelete, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", dir, "--since", before, "--force", "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.Contains(t, output, "Dashboard") + assert.Contains(t, output, "a1b2c3d4-5678-90ab-cdef-1234567890ab") + assert.Contains(t, output, "deleted") +} + +// TestApply_Since_WholeFileDeletion_SubdirectoryScope is a regression test +// for a bug where -f pointed at a subdirectory of the repo (rather than the +// repo root) made --since silently report zero deletions: the git-side +// pathspec is always repo-root-relative, but the git plumbing calls were +// running with -C set to the scope directory itself, so the pathspec +// resolved to a nonexistent nested path (e.g. "dashboards/dashboards") and +// git ls-tree returned an empty (not an error) result. This is the CLI's own +// documented apply --since usage pattern (-f dashboards/), so it must work. +func TestApply_Since_WholeFileDeletion_SubdirectoryScope(t *testing.T) { + testutil.SetupTestEnv(t) + + repoRoot := t.TempDir() + runGitCmd(t, repoRoot, "init", "-q", "-b", "main") + runGitCmd(t, repoRoot, "config", "user.email", "test@example.com") + runGitCmd(t, repoRoot, "config", "user.name", "Test") + runGitCmd(t, repoRoot, "config", "commit.gpgsign", "false") + + writeFileFixture(t, repoRoot, "dashboards/dashboard.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +`) + runGitCmd(t, repoRoot, "add", "-A") + runGitCmd(t, repoRoot, "commit", "-q", "-m", "add dashboard") + before := strings.TrimSpace(runGitCmd(t, repoRoot, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(repoRoot, "dashboards", "dashboard.yaml"))) + writeFileFixture(t, repoRoot, "dashboards/keep.yaml", "apiVersion: dash0.com/v1alpha1\nkind: View\nmetadata:\n name: keep\n labels:\n dash0.com/id: keep-id\nspec:\n query: \"true\"\n") + runGitCmd(t, repoRoot, "add", "-A") + runGitCmd(t, repoRoot, "commit", "-q", "-m", "remove dashboard") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureViewsNotFound, + }) + server.WithViewsUpdate(testutil.FixtureViewsImportSuccess) + server.OnPattern(http.MethodDelete, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", filepath.Join(repoRoot, "dashboards"), "--since", before, "--force", "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.Contains(t, output, "Dashboard") + assert.Contains(t, output, "a1b2c3d4-5678-90ab-cdef-1234567890ab") + assert.Contains(t, output, "deleted") + + deleteReq := findRequest(server.Requests(), http.MethodDelete, "/api/dashboards/a1b2c3d4-5678-90ab-cdef-1234567890ab") + require.NotNil(t, deleteReq, "expected a DELETE request for the dashboard removed from the subdirectory-scoped repo") +} + +func TestApply_Since_PrometheusRuleAlertPartialDeletion(t *testing.T) { + testutil.SetupTestEnv(t) + + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "rules.yaml", `apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: test-rules + labels: + dash0.com/id: shared-id +spec: + groups: + - name: test-group + rules: + - alert: HighErrorRate + expr: sum(rate(errors[5m])) > 0.1 + - alert: DiskFull + expr: disk > 0.9 +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add rules with two alerts") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + // Remove the DiskFull alert; the CRD (and its shared identifier) survives. + writeFileFixture(t, dir, "rules.yaml", `apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: test-rules + labels: + dash0.com/id: shared-id +spec: + groups: + - name: test-group + rules: + - alert: HighErrorRate + expr: sum(rate(errors[5m])) > 0.1 +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove DiskFull alert") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + // The surviving alert (HighErrorRate) shares the CRD's dash0.com/id label + // (existing product behavior — see docs/commands.md's PrometheusRule + // note on multi-alert CRDs sharing one identifier), so it upserts via + // PUT to that id rather than going through the GET-404-then-POST path. + server.OnPattern(http.MethodGet, checkRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureCheckRulesNotFound, + }) + server.WithCheckRulesUpdate(testutil.FixtureCheckRulesImportSuccess) + // The removed alert (DiskFull) must be resolved to a check rule by name, + // since the CRD's shared identifier can't distinguish between its alerts. + server.On(http.MethodGet, apiPathCheckRules, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: []map[string]any{ + {"dataset": "default", "id": "disk-full-check-rule-id", "name": "test-group - DiskFull"}, + }, + Validator: testutil.RequireHeaders, + }) + server.OnPattern(http.MethodDelete, checkRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", dir, "--since", before, "--force", "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.Contains(t, output, "test-group - DiskFull") + assert.Contains(t, output, "deleted") + + deleteReq := findRequest(server.Requests(), http.MethodDelete, "/api/alerting/check-rules/disk-full-check-rule-id") + require.NotNil(t, deleteReq, "expected a DELETE request for the removed alert's resolved check rule id") +} + +// TestApply_Since_PersesDashboardAlreadyDeletedPreservesCanonicalKindName is a +// regression test for deleteAssetByKindAndIdentifier's client.ErrorContext +// construction: it must pass asset.KindDisplayName's canonical form straight +// through (no case transform at all), so a compound kind name like +// "PersesDashboard" is never mangled into "persesdashboard" (a plain +// strings.ToLower) or an invented hybrid like "persesDashboard" (an earlier, +// since-reverted lowerFirst-only-first-rune attempt) — both are stand-ins for +// a kind name that doesn't correspond to anything real. Exercised via the +// --force "already deleted" idempotent-delete message +// (client.IsAlreadyDeleted -> capitalizeFirst(ectx.AssetType)), which is +// idempotent no matter the input casing, making it the cleanest place to +// observe AssetType's actual value. +func TestApply_Since_PersesDashboardAlreadyDeletedPreservesCanonicalKindName(t *testing.T) { + testutil.SetupTestEnv(t) + + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "dashboard.yaml", `apiVersion: perses.dev/v1alpha1 +kind: PersesDashboard +metadata: + name: my-perses-dashboard + labels: + dash0.com/id: perses-id +spec: + display: + name: My Perses Dashboard + duration: 5m + panels: {} +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add perses dashboard") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(dir, "dashboard.yaml"))) + writeFileFixture(t, dir, "keep.yaml", "apiVersion: dash0.com/v1alpha1\nkind: View\nmetadata:\n name: keep\n labels:\n dash0.com/id: keep-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove perses dashboard") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureViewsNotFound, + }) + server.WithViewsUpdate(testutil.FixtureViewsImportSuccess) + // The live asset is already gone: DELETE 404s. With --force this must be + // treated as an idempotent success (client.IsAlreadyDeleted), printing + // "PersesDashboard ... was already deleted" to stderr. + server.OnPattern(http.MethodDelete, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureDashboardsNotFound, + }) + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", dir, "--since", before, "--force", "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + stderr := testutil.CaptureStderr(t, func() { + testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + }) + + require.NoError(t, cmdErr) + assert.Contains(t, stderr, "PersesDashboard \"perses-id\" was already deleted") + assert.NotContains(t, stderr, "persesdashboard", "the whole kind name must never be force-lowercased into an unreadable compound word") + assert.NotContains(t, stderr, "persesDashboard", "the kind name must never be turned into an invented hybrid casing either") +} + +// TestApply_Since_PrometheusRuleWholeCRDDeletion_AlertingOnlyDoesNotTouchRecordingRules +// is a regression test for a bug where deleting a whole PrometheusRule CRD +// unconditionally attempted DELETE on both the check-rules and +// recording-rules endpoints, tolerating a 404 from whichever the CRD didn't +// use. If an unrelated, still-live recording rule happened to share the same +// identifier (a coincidental id collision — the two asset types have +// entirely separate id spaces on the server, so this is possible), the old +// code would silently delete it too, since a successful DELETE there looks +// identical to "the CRD used this endpoint." The fix carries forward which +// endpoint(s) the CRD's content actually used (from the git ref before it +// was deleted), so an alerting-only CRD's deletion is dispatched to +// check-rules only. +func TestApply_Since_PrometheusRuleWholeCRDDeletion_AlertingOnlyDoesNotTouchRecordingRules(t *testing.T) { + testutil.SetupTestEnv(t) + + const sharedID = "shared-id-collision" + + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "rules.yaml", `apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: alerting-only-rules + labels: + dash0.com/id: `+sharedID+` +spec: + groups: + - name: test-group + rules: + - alert: HighErrorRate + expr: sum(rate(errors[5m])) > 0.1 +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add alerting-only rules") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(dir, "rules.yaml"))) + writeFileFixture(t, dir, "keep.yaml", "apiVersion: dash0.com/v1alpha1\nkind: View\nmetadata:\n name: keep\n labels:\n dash0.com/id: keep-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove alerting-only rules") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureViewsNotFound, + }) + server.WithViewsUpdate(testutil.FixtureViewsImportSuccess) + server.OnPattern(http.MethodDelete, checkRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + // An unrelated, still-live recording rule that happens to share the same + // identifier: registered to SUCCEED, so this test fails loudly if the + // buggy code path calls it at all. + server.OnPattern(http.MethodDelete, recordingRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", dir, "--since", before, "--force", "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.Contains(t, output, "PrometheusRule") + assert.Contains(t, output, sharedID) + assert.Contains(t, output, "deleted") + + require.NotNil(t, findRequest(server.Requests(), http.MethodDelete, "/api/alerting/check-rules/"+sharedID), "expected the alerting-only CRD's check rule to be deleted") + assert.Nil(t, findRequest(server.Requests(), http.MethodDelete, "/api/recording-rules/"+sharedID), "an alerting-only CRD must never call DELETE on the recording-rules endpoint, even if something there happens to share its identifier") +} + +func TestApply_Since_MultiDocumentPartialDeletion(t *testing.T) { + testutil.SetupTestEnv(t) + + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "combined.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +--- +apiVersion: dash0.com/v1alpha1 +kind: View +metadata: + name: my-view + labels: + dash0.com/id: my-view-id +spec: + query: "true" +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add combined file") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + // Remove the View document; the Dashboard document (and the file) survives. + writeFileFixture(t, dir, "combined.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove view document") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureDashboardsNotFound, + }) + server.WithDashboardsUpdate(testutil.FixtureDashboardsImportSuccess) + server.OnPattern(http.MethodDelete, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", dir, "--since", before, "--force", "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.Contains(t, output, "View") + assert.Contains(t, output, "my-view-id") + assert.Contains(t, output, "deleted") + + deleteReq := findRequest(server.Requests(), http.MethodDelete, "/api/views/my-view-id") + require.NotNil(t, deleteReq, "expected a DELETE request for the view document removed from the surviving file") +} + +func TestApply_Since_PrometheusRecordingRulePartialRemovalIsNotADeletion(t *testing.T) { + testutil.SetupTestEnv(t) + + const ruleID = "f47ac10b-58cc-4372-a567-0e02b2c3d479" + + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "rules.yaml", `apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: mixed-rules + labels: + dash0.com/id: `+ruleID+` +spec: + groups: + - name: mixed-group + interval: 1m + rules: + - alert: HighErrorRate + expr: sum(rate(errors[5m])) > 0.1 + - record: instance:cpu_usage:avg5m + expr: avg without(cpu) (rate(node_cpu_seconds_total{mode!="idle"}[5m])) +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add mixed rules") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + // Remove the recording rule; the alert (and the CRD's shared identifier) + // survives. This is not tracked as a deletion at all — no per-record + // identity exists to diff, so it is a plain update to the surviving CRD. + writeFileFixture(t, dir, "rules.yaml", `apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: mixed-rules + labels: + dash0.com/id: `+ruleID+` +spec: + groups: + - name: mixed-group + interval: 1m + rules: + - alert: HighErrorRate + expr: sum(rate(errors[5m])) > 0.1 +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove recording rule") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, checkRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureCheckRulesNotFound, + }) + server.WithCheckRulesUpdate(testutil.FixtureCheckRulesImportSuccess) + // No recording-rules route registered at all: the removed record entry + // must never trigger a call to that endpoint, delete or otherwise. + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", dir, "--since", before, "--force", "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.Nil(t, findRequest(server.Requests(), http.MethodDelete, apiPathRecordingRules+"/"+ruleID), "removing a record entry from a surviving CRD must not be treated as a deletion") + require.NotNil(t, findRequest(server.Requests(), http.MethodPut, apiPathCheckRules+"/"+ruleID), "the surviving alert must still go through the ordinary update path") +} + +func TestApply_Since_UnresolvableRef(t *testing.T) { + testutil.SetupTestEnv(t) + + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + writeFileFixture(t, dir, "keep.yaml", "apiVersion: dash0.com/v1alpha1\nkind: View\nmetadata:\n name: keep\n labels:\n dash0.com/id: keep-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add file") + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{"-f", dir, "--since", "totally-bogus-ref", "--experimental"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "could not be resolved") +} + +// setUpNonAncestorRefRepo builds a repo where "branch-a" (containing a View) +// diverges from "main" (which never merges it back in) — a stand-in for a +// force-pushed/rewritten history, the same construction +// TestClassifyRef_ResolvedNonAncestor uses. main also gets an unrelated +// Dashboard with no user-defined id, present only on main, so a plain create +// is expected regardless of what --since decides about the View. +func setUpNonAncestorRefRepo(t *testing.T) (dir, branchA string) { + t.Helper() + dir = t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + runGitCmd(t, dir, "commit", "-q", "--allow-empty", "-m", "initial") + + runGitCmd(t, dir, "checkout", "-q", "-b", "branch-a") + writeFileFixture(t, dir, "assets/a.yaml", "apiVersion: dash0.com/v1alpha1\nkind: View\nmetadata:\n name: a\n labels:\n dash0.com/id: a-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "branch a commit") + branchA = strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + runGitCmd(t, dir, "checkout", "-q", "main") + writeFileFixture(t, dir, "assets/keep-dashboard.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: keep-dashboard +spec: + display: + name: Keep Dashboard +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "main commit") + + return dir, branchA +} + +// TestApply_Since_NonAncestorRef_DeclinedDeletionDoesNotBlockCreates is a +// regression test for a bug where a declined (or unconfirmable) +// confirmation for a non-ancestor --since ref aborted the entire apply run +// before any document was processed — including ordinary creates/updates +// that have nothing to do with --since's ancestry check. The confirmation +// must gate only the deletion phase, after every other document has already +// gone through. +func TestApply_Since_NonAncestorRef_DeclinedDeletionDoesNotBlockCreates(t *testing.T) { + testutil.SetupTestEnv(t) + dir, branchA := setUpNonAncestorRefRepo(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureDashboardsNotFound, + }) + server.WithDashboardsCreate(testutil.FixtureDashboardsImportSuccess) + // No DELETE route registered for views: the decline must prevent any + // delete call from ever being attempted. + + restore := confirmation.SetReaderForTest(strings.NewReader("n\n")) + defer restore() + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", filepath.Join(dir, "assets"), "--since", branchA, "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.Error(t, cmdErr, "the run must still report a non-zero exit for the skipped deletion") + assert.Contains(t, cmdErr.Error(), "not confirmed for deletion") + assert.Contains(t, output, "Dashboard") + assert.Contains(t, output, "created", "the unrelated create must succeed despite the declined deletion confirmation") + + createReq := findRequest(server.Requests(), http.MethodPost, apiPathDashboards) + require.NotNil(t, createReq, "expected the unrelated dashboard to still be created") + assert.Nil(t, findRequest(server.Requests(), http.MethodDelete, apiPathViews), "no delete call must be attempted once the ref confirmation is declined") +} + +// TestApply_Since_NonAncestorRef_NoTerminalDoesNotBlockCreates mirrors +// TestApply_Since_NonAncestorRef_DeclinedDeletionDoesNotBlockCreates for the +// no-terminal-available case (stdin closed immediately, simulating a +// non-interactive CI run without --force) — it must fail the same way a +// decline does, not hang or silently proceed with deletions. +func TestApply_Since_NonAncestorRef_NoTerminalDoesNotBlockCreates(t *testing.T) { + testutil.SetupTestEnv(t) + dir, branchA := setUpNonAncestorRefRepo(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureDashboardsNotFound, + }) + server.WithDashboardsCreate(testutil.FixtureDashboardsImportSuccess) + + restore := confirmation.SetReaderForTest(strings.NewReader("")) + defer restore() + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", filepath.Join(dir, "assets"), "--since", branchA, "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.Error(t, cmdErr) + assert.Contains(t, output, "Dashboard") + assert.Contains(t, output, "created", "the unrelated create must succeed even when the deletion confirmation can't be obtained") + assert.Nil(t, findRequest(server.Requests(), http.MethodDelete, apiPathViews), "no delete call must be attempted when confirmation can't be obtained") +} + +// TestApply_Since_NonAncestorRef_ForceDeletesAndWarnsOnce is a regression +// test for a bug where the non-ancestor warning was printed twice: once as +// part of the confirmation prompt (now removed — see the 4.5 deviation note +// in tasks.md), and again unconditionally at the top of applyDeletions. With +// --force set, the prompt is skipped entirely, so this specifically checks +// that a run which actually goes on to perform the deletion still only ever +// shows the warning once. +func TestApply_Since_NonAncestorRef_ForceDeletesAndWarnsOnce(t *testing.T) { + testutil.SetupTestEnv(t) + dir, branchA := setUpNonAncestorRefRepo(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureDashboardsNotFound, + }) + server.WithDashboardsCreate(testutil.FixtureDashboardsImportSuccess) + server.OnPattern(http.MethodDelete, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", filepath.Join(dir, "assets"), "--since", branchA, "--force", "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + var stdout string + stderr := testutil.CaptureStderr(t, func() { + stdout = testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + }) + + require.NoError(t, cmdErr) + assert.Contains(t, stdout, "Dashboard") + assert.Contains(t, stdout, "created") + assert.Contains(t, stdout, "View") + assert.Contains(t, stdout, "deleted") + assert.Equal(t, 1, strings.Count(stderr, "not an ancestor of HEAD"), "the non-ancestor warning must be printed exactly once, not once for the prompt and once again in applyDeletions") + + deleteReq := findRequest(server.Requests(), http.MethodDelete, "/api/views/a-id") + require.NotNil(t, deleteReq, "expected the view removed since branchA to be deleted with --force") +} + +func TestApply_Since_DeclinedDeletionFailsCommand(t *testing.T) { + testutil.SetupTestEnv(t) + + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "dashboard.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add dashboard") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(dir, "dashboard.yaml"))) + writeFileFixture(t, dir, "keep.yaml", "apiVersion: dash0.com/v1alpha1\nkind: View\nmetadata:\n name: keep\n labels:\n dash0.com/id: keep-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove dashboard") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureViewsNotFound, + }) + server.WithViewsUpdate(testutil.FixtureViewsImportSuccess) + // No DELETE route registered: the deletion must be declined before any + // delete call is attempted. + + restore := confirmation.SetReaderForTest(strings.NewReader("n\n")) + defer restore() + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", dir, "--since", before, "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "declined") +} + +func TestApply_Since_DryRunPreviewDoesNotDelete(t *testing.T) { + testutil.SetupTestEnv(t) + + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "dashboard.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add dashboard") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(dir, "dashboard.yaml"))) + writeFileFixture(t, dir, "keep.yaml", "apiVersion: dash0.com/v1alpha1\nkind: View\nmetadata:\n name: keep\n labels:\n dash0.com/id: keep-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove dashboard") + + // No mock server at all: --dry-run --since must never make an API call. + cmd := newSinceTestCmd() + cmd.SetArgs([]string{"-f", dir, "--since", before, "--dry-run", "--experimental"}) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.Contains(t, output, "would delete") + assert.Contains(t, output, "a1b2c3d4-5678-90ab-cdef-1234567890ab") +} + +// TestApply_Since_SpamFilterIDOnlyDeletionWarns is a regression test for a +// gap where deleting a spam filter identified by dash0.com/id alone gave no +// indication that the id recorded in git history might no longer match the +// filter's actual live id (the server reassigns an ID-only filter's id on +// its first PUT — see asset.ImportSpamFilter) — the delete could silently +// miss the real live filter with no diagnostic at all. +func TestApply_Since_SpamFilterIDOnlyDeletionWarns(t *testing.T) { + testutil.SetupTestEnv(t) + + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "filter.yaml", `apiVersion: v1alpha1 +kind: Dash0SpamFilter +metadata: + name: Drop noisy health checks + labels: + dash0.com/id: spam-id-only +spec: + contexts: + - log + filter: + - key: http.target + operator: ends_with + value: /healthz +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add spam filter") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(dir, "filter.yaml"))) + writeFileFixture(t, dir, "keep.yaml", "apiVersion: dash0.com/v1alpha1\nkind: View\nmetadata:\n name: keep\n labels:\n dash0.com/id: keep-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove spam filter") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureViewsNotFound, + }) + server.WithViewsUpdate(testutil.FixtureViewsImportSuccess) + server.On(http.MethodDelete, "/api/spam-filters/spam-id-only", testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", dir, "--since", before, "--force", "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + stderr := testutil.CaptureStderr(t, func() { + testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + }) + + require.NoError(t, cmdErr) + assert.Contains(t, stderr, "spam filter \"spam-id-only\" was identified by dash0.com/id alone") +} + +// TestApply_Since_SpamFilterOriginDeletionDoesNotWarn confirms the warning +// above is precise: a spam filter identified by dash0.com/origin (which is +// never reassigned server-side) must not trigger it. +func TestApply_Since_SpamFilterOriginDeletionDoesNotWarn(t *testing.T) { + testutil.SetupTestEnv(t) + + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "filter.yaml", `apiVersion: v1alpha1 +kind: Dash0SpamFilter +metadata: + name: Drop noisy health checks + labels: + dash0.com/origin: spam-origin +spec: + contexts: + - log + filter: + - key: http.target + operator: ends_with + value: /healthz +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add spam filter") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(dir, "filter.yaml"))) + writeFileFixture(t, dir, "keep.yaml", "apiVersion: dash0.com/v1alpha1\nkind: View\nmetadata:\n name: keep\n labels:\n dash0.com/id: keep-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove spam filter") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureViewsNotFound, + }) + server.WithViewsUpdate(testutil.FixtureViewsImportSuccess) + server.On(http.MethodDelete, "/api/spam-filters/spam-origin", testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", dir, "--since", before, "--force", "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + stderr := testutil.CaptureStderr(t, func() { + testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + }) + + require.NoError(t, cmdErr) + assert.NotContains(t, stderr, "identified by dash0.com/id alone", "an origin-identified spam filter's id is never reassigned, so no warning is needed") +} diff --git a/internal/apply/since_test.go b/internal/apply/since_test.go new file mode 100644 index 00000000..c9b94b80 --- /dev/null +++ b/internal/apply/since_test.go @@ -0,0 +1,263 @@ +package apply + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/dash0hq/dash0-cli/internal/confirmation" + gitutil "github.com/dash0hq/dash0-cli/internal/git" + "github.com/dash0hq/dash0-cli/internal/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// withExperimentalFlag registers a local --experimental/-X bool flag on cmd, +// standing in for the persistent flag main.go registers on the real root +// command. Standalone tests that construct NewApplyCmd() directly (with no +// root parent) need this for experimental.RequireExperimentalFlag's +// cmd.Flags().GetBool("experimental") lookup to succeed instead of silently +// treating the flag as unregistered (=> always disabled). +func withExperimentalFlag(cmd *cobra.Command) { + cmd.Flags().BoolP("experimental", "X", false, "Enable experimental features") +} + +func TestApply_Since_RequiresExperimentalFlag(t *testing.T) { + cmd := NewApplyCmd() + withExperimentalFlag(cmd) + cmd.SetArgs([]string{"-f", "does-not-need-to-exist.yaml", "--since", "HEAD~1"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--since") + assert.Contains(t, err.Error(), "--experimental") +} + +func TestApply_Since_NotPassedIsUngated(t *testing.T) { + // --since not passed at all: the gate must be a no-op, and the command + // should fail for the ordinary "file not found" reason, never mentioning + // --experimental. + cmd := NewApplyCmd() + withExperimentalFlag(cmd) + cmd.SetArgs([]string{"-f", "does-not-exist.yaml"}) + + err := cmd.Execute() + require.Error(t, err) + assert.NotContains(t, err.Error(), "--experimental") +} + +func TestApply_Since_RejectsStdin(t *testing.T) { + cmd := NewApplyCmd() + withExperimentalFlag(cmd) + cmd.SetArgs([]string{"-f", "-", "--since", "HEAD~1", "--experimental"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "stdin") +} + +// TestApply_Since_ExplicitEmptyStringHitsRefEmptyError is a regression test +// for a bug where runApply gated computing the deletion plan on +// flags.Since != "", conflating "--since was never passed" with "--since was +// passed with an explicitly empty value". A CI script building +// --since="${{ github.event.before }}" can legitimately pass an empty string +// (e.g. on a workflow_dispatch/schedule trigger with no prior ref), and that +// must still surface the dedicated "no prior state to compare against" +// error, not silently fall through to an ordinary create/update apply. +func TestApply_Since_ExplicitEmptyStringHitsRefEmptyError(t *testing.T) { + dir, _ := testSinceRepo(t) + + cmd := NewApplyCmd() + withExperimentalFlag(cmd) + cmd.SetArgs([]string{"-f", dir, "--since", "", "--experimental"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "no prior state to compare against") +} + +// testSinceRepo creates a temp git repo with a dashboard file at ref +// "before", then removes it in a later commit ("after" / HEAD), returning +// the repo directory and the "before" ref's SHA. +func testSinceRepo(t *testing.T) (dir, beforeSHA string) { + t.Helper() + dir = t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "dashboard.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add dashboard") + beforeSHA = strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(dir, "dashboard.yaml"))) + // Leave an unrelated file so the directory isn't empty for readDirectory. + writeFileFixture(t, dir, "unrelated.yaml", "kind: View\nmetadata:\n name: unrelated\n labels:\n dash0.com/id: unrelated-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove dashboard") + + return dir, beforeSHA +} + +func runGitCmd(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %v failed: %s", args, out) + return string(out) +} + +func writeFileFixture(t *testing.T, dir, relPath, content string) { + t.Helper() + full := filepath.Join(dir, relPath) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(content), 0o644)) +} + +func TestComputeDeletionPlan_WholeFileDeletion(t *testing.T) { + dir, before := testSinceRepo(t) + + flags := &applyFlags{File: dir, Since: before} + dp, err := computeDeletionPlan(context.Background(), flags) + require.NoError(t, err) + require.Len(t, dp.plan.ByIdentifier, 1) + assert.Equal(t, "dashboard", dp.plan.ByIdentifier[0].Kind) + assert.Equal(t, "a1b2c3d4-5678-90ab-cdef-1234567890ab", dp.plan.ByIdentifier[0].Identifier) + assert.Empty(t, dp.warning) +} + +func TestComputeDeletionPlan_EmptyRef(t *testing.T) { + dir, _ := testSinceRepo(t) + flags := &applyFlags{File: dir, Since: ""} + _, err := computeDeletionPlan(context.Background(), flags) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty ref") +} + +func TestComputeDeletionPlan_AllZerosRef(t *testing.T) { + dir, _ := testSinceRepo(t) + flags := &applyFlags{File: dir, Since: "0000000000000000000000000000000000000000"} + _, err := computeDeletionPlan(context.Background(), flags) + require.Error(t, err) + assert.Contains(t, err.Error(), "all-zeros") +} + +func TestComputeDeletionPlan_UnresolvableRef(t *testing.T) { + dir, _ := testSinceRepo(t) + flags := &applyFlags{File: dir, Since: "totally-bogus-ref"} + _, err := computeDeletionPlan(context.Background(), flags) + require.Error(t, err) + assert.Contains(t, err.Error(), "could not be resolved") +} + +func TestComputeDeletionPlan_NoIdentifierHardFails(t *testing.T) { + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "orphan.yaml", "kind: Dashboard\nmetadata:\n name: no-id\n") + writeFileFixture(t, dir, "keep.yaml", "kind: View\nmetadata:\n name: keep\n labels:\n dash0.com/id: keep-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add files") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(dir, "orphan.yaml"))) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove orphan") + + flags := &applyFlags{File: dir, Since: before} + _, err := computeDeletionPlan(context.Background(), flags) + require.Error(t, err) + assert.Contains(t, err.Error(), "no dash0.com/id or dash0.com/origin label") + assert.Contains(t, err.Error(), "orphan.yaml") +} + +// TestComputeDeletionPlan_NonAncestorRef_NeverPromptsOrErrors documents +// computeDeletionPlan's contract after the fix for a bug where its own +// confirmation prompt for a non-ancestor ref aborted the entire apply run — +// including ordinary creates/updates unrelated to --since — before any +// document was even processed. computeDeletionPlan itself no longer prompts +// at all: it always returns the plan plus a warning for the caller to act +// on. The confirmation now lives in runApply, gated to run only right before +// the deletion phase, after every other document has already been applied — +// see TestApply_Since_NonAncestorRef_DeclinedDeletionDoesNotBlockCreates and +// TestApply_Since_NonAncestorRef_NoTerminalDoesNotBlockCreates in +// since_integration_test.go for that behavior. +// +// No reader override is installed here: if computeDeletionPlan tried to +// prompt, reading from the real os.Stdin in a test process would hang or +// fail — getting NoError without one is exactly what proves it never does. +func TestComputeDeletionPlan_NonAncestorRef_NeverPromptsOrErrors(t *testing.T) { + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + runGitCmd(t, dir, "commit", "-q", "--allow-empty", "-m", "initial") + + runGitCmd(t, dir, "checkout", "-q", "-b", "branch-a") + writeFileFixture(t, dir, "a.yaml", "kind: View\nmetadata:\n name: a\n labels:\n dash0.com/id: a-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "branch a commit") + branchA := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + runGitCmd(t, dir, "checkout", "-q", "main") + writeFileFixture(t, dir, "b.yaml", "kind: View\nmetadata:\n name: b\n labels:\n dash0.com/id: b-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "main commit") + + flags := &applyFlags{File: dir, Since: branchA} + dp, err := computeDeletionPlan(context.Background(), flags) + require.NoError(t, err) + assert.Contains(t, dp.warning, "not an ancestor of HEAD") +} + +// TestApplyDeletions_PrometheusRuleConfirmationPromptUsesConsistentCasing is +// a regression test for a bug where applyDeletions lowercased +// asset.KindDisplayName's entire output (strings.ToLower) specifically for +// the confirmation prompt text (printed to stdout by +// confirmation.ConfirmDestructiveOperation via fmt.Print, not the "declined" +// message on stderr, which was never affected), mangling a compound proper +// noun like "PrometheusRule" into unreadable "prometheusrule" — while the +// success message for the exact same asset used the correctly-cased +// "PrometheusRule". No API call happens on the declined path, so this +// exercises the message text directly without needing a mock server. +func TestApplyDeletions_PrometheusRuleConfirmationPromptUsesConsistentCasing(t *testing.T) { + restore := confirmation.SetReaderForTest(strings.NewReader("n\n")) + defer restore() + + dp := &deletionPlan{ + plan: gitutil.DeletionPlan{ + ByIdentifier: []gitutil.Deletion{ + {Kind: "prometheusrule", Identifier: "shared-id"}, + }, + }, + } + + stdout := testutil.CaptureStdout(t, func() { + declined, err := applyDeletions(context.Background(), nil, nil, dp, false) + require.NoError(t, err) + assert.Equal(t, 1, declined) + }) + + assert.Contains(t, stdout, "Are you sure you want to delete PrometheusRule \"shared-id\"") + assert.NotContains(t, stdout, "prometheusrule", "the whole display name must never be force-lowercased into an unreadable compound word") +} + diff --git a/internal/asset/kind.go b/internal/asset/kind.go index b5630d89..2526a781 100644 --- a/internal/asset/kind.go +++ b/internal/asset/kind.go @@ -4,14 +4,35 @@ import ( "strings" ) +// NormalizeKind lowercases kind, strips "-"/"_", and trims a leading "dash0" +// prefix, so callers can compare kind strings regardless of how they were +// cased or hyphenated in the source document (e.g. "Dash0Team" and "team" +// both normalize to "team"). +func NormalizeKind(kind string) string { + k := strings.ToLower(kind) + k = strings.ReplaceAll(k, "-", "") + k = strings.ReplaceAll(k, "_", "") + return strings.TrimPrefix(k, "dash0") +} + +// IsValidKind reports whether kind (in any casing/hyphenation NormalizeKind +// accepts) is one of the Dash0 asset kinds apply/create/--since know how to +// handle. Used to distinguish a genuine Dash0 document from unrelated YAML +// (e.g. a stray Kubernetes ConfigMap) that happens to sit in a scanned scope. +func IsValidKind(kind string) bool { + switch NormalizeKind(kind) { + case "dashboard", "checkrule", "syntheticcheck", "view", "prometheusrule", "persesdashboard", "spamfilter", "notificationchannel", "team": + return true + default: + return false + } +} + // KindDisplayName returns the human-readable name for an asset kind. // Multi-word kinds like "CheckRule" become "Check rule" and "SyntheticCheck" // becomes "Synthetic check". func KindDisplayName(kind string) string { - k := strings.ToLower(kind) - k = strings.ReplaceAll(k, "-", "") - k = strings.ReplaceAll(k, "_", "") - k = strings.TrimPrefix(k, "dash0") + k := NormalizeKind(kind) switch k { case "dashboard": return "Dashboard" diff --git a/internal/asset/prometheusrule.go b/internal/asset/prometheusrule.go index 2ca52302..a5145d9e 100644 --- a/internal/asset/prometheusrule.go +++ b/internal/asset/prometheusrule.go @@ -6,6 +6,7 @@ import ( dash0api "github.com/dash0hq/dash0-api-client-go" dash0yaml "github.com/dash0hq/dash0-api-client-go/yaml" + "gopkg.in/yaml.v3" sigsyaml "sigs.k8s.io/yaml" ) @@ -30,6 +31,32 @@ func ParseCheckRules(data []byte) ([]*dash0api.PrometheusAlertRule, error) { return rules, nil } +// PrometheusRuleEndpoints reports which of the two Dash0 endpoints (check +// rules for alerting rules, recording rules for recording rules) a +// PrometheusRule CRD document actually uses. Returns false, false for a +// document that isn't a PrometheusRule CRD at all. +// +// --since uses this to delete a removed CRD only from the endpoint(s) it +// actually used, instead of unconditionally attempting both and tolerating a +// 404 from whichever wasn't used — an id happening to also exist, +// coincidentally, on the endpoint the CRD never used would otherwise be +// silently deleted too. +func PrometheusRuleEndpoints(data []byte) (hasAlerts, hasRecords bool, err error) { + kind, err := dash0yaml.DetectKind(data) + if err != nil { + return false, false, err + } + if !strings.EqualFold(kind, "PrometheusRule") { + return false, false, nil + } + + var crd dash0api.RecordingRule + if err := sigsyaml.Unmarshal(data, &crd); err != nil { + return false, false, fmt.Errorf("failed to parse PrometheusRule: %w", err) + } + return PrometheusRuleHasAlerts(&crd), RecordingOnlyPrometheusRule(&crd) != nil, nil +} + // composePrometheusRuleNames rewrites the name of each check rule produced from // a PrometheusRule CRD to " - ". It is a no-op for // plain CheckRule documents. @@ -47,22 +74,79 @@ func composePrometheusRuleNames(data []byte, rules []*dash0api.PrometheusAlertRu return nil } - var crd dash0api.RecordingRule - if err := sigsyaml.Unmarshal(data, &crd); err != nil { - return fmt.Errorf("failed to parse PrometheusRule: %w", err) + names, err := ExtractPrometheusAlertNames(data) + if err != nil { + return err + } + for i, name := range names { + if i >= len(rules) { + return nil + } + rules[i].Name = name.CheckRuleName() } + return nil +} - i := 0 - for _, group := range crd.Spec.Groups { - for _, rule := range group.Rules { - if rule.Alert == nil || *rule.Alert == "" { +// ExtractPrometheusAlertNames parses a PrometheusRule CRD document and +// returns the (group name, alert name) pair for every alerting rule, in +// document order. Recording rules are skipped. +// +// Unlike a struct-typed unmarshal (sigs.k8s.io/yaml decoding into a *string +// field, as PrometheusRuleEndpoints and the rest of this file otherwise +// use), this reads each name's literal scalar value directly off the raw +// YAML node tree. sigs.k8s.io/yaml's YAML->JSON->struct path resolves an +// unquoted YAML 1.1/1.2 boolean literal (Y, N, yes, no, on, off, true, +// false, and case variants) to a real JSON boolean, then silently coerces +// that boolean into the destination string field as "true"/"false" instead +// of erroring — so an alert genuinely named e.g. "Y" would otherwise be +// corrupted to "true" everywhere its name is used (the composed check-rule +// name here, and --since's alert-tracking diff in internal/git, which calls +// this function via internal/git/snapshot.go instead of +// dash0-api-client-go/yaml's identically-named, differently-implemented +// ExtractPrometheusAlertNames for exactly this reason). +func ExtractPrometheusAlertNames(data []byte) ([]dash0yaml.PrometheusAlertName, error) { + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + if len(doc.Content) == 0 { + return nil, nil + } + groups := yamlMapValue(yamlMapValue(doc.Content[0], "spec"), "groups") + if groups == nil { + return nil, nil + } + + var names []dash0yaml.PrometheusAlertName + for _, group := range groups.Content { + groupName := "" + if n := yamlMapValue(group, "name"); n != nil { + groupName = n.Value + } + rules := yamlMapValue(group, "rules") + if rules == nil { + continue + } + for _, rule := range rules.Content { + alert := yamlMapValue(rule, "alert") + if alert == nil || alert.Value == "" { continue } - if i >= len(rules) { - return nil - } - rules[i].Name = fmt.Sprintf("%s - %s", group.Name, *rule.Alert) - i++ + names = append(names, dash0yaml.PrometheusAlertName{GroupName: groupName, AlertName: alert.Value}) + } + } + return names, nil +} + +// yamlMapValue returns the value node for key within a YAML mapping node, or +// nil if node is nil, not a mapping, or key isn't present. +func yamlMapValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] } } return nil diff --git a/internal/asset/prometheusrule_test.go b/internal/asset/prometheusrule_test.go index 6dd7a2b8..e3725f94 100644 --- a/internal/asset/prometheusrule_test.go +++ b/internal/asset/prometheusrule_test.go @@ -59,6 +59,124 @@ spec: assert.Equal(t, "group-b - DiskFull", rules[1].Name) } +// TestParseCheckRules_BooleanLiteralAlertNamePreserved is a regression test +// for a bug where an alert name that is a YAML boolean literal (Y, N, yes, +// no, on, off, true, false, and case variants), written unquoted, was +// silently corrupted to "true"/"false": sigs.k8s.io/yaml's YAML->JSON->struct +// unmarshal path resolves the literal to a real JSON boolean, then coerces +// it into the destination *string field instead of erroring. Confirmed +// directly, unmarshaling `alert: Y` into a struct with an `Alert *string` +// field sets Alert to "true", not "Y". +func TestParseCheckRules_BooleanLiteralAlertNamePreserved(t *testing.T) { + cases := []string{"Y", "N", "yes", "No", "ON", "off", "true", "False"} + for _, alertName := range cases { + crd := []byte("apiVersion: monitoring.coreos.com/v1\n" + + "kind: PrometheusRule\n" + + "metadata:\n" + + " name: boolean-literal-test\n" + + "spec:\n" + + " groups:\n" + + " - name: g\n" + + " rules:\n" + + " - alert: " + alertName + "\n" + + " expr: up == 0\n") + + rules, err := ParseCheckRules(crd) + require.NoError(t, err, "alert name %q", alertName) + require.Len(t, rules, 1, "alert name %q", alertName) + assert.Equal(t, "g - "+alertName, rules[0].Name, "alert name %q must be preserved verbatim, not coerced into a boolean and re-stringified", alertName) + } +} + +func TestExtractPrometheusAlertNames_BooleanLiteralPreserved(t *testing.T) { + crd := []byte(`apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: boolean-literal-test +spec: + groups: + - name: g + rules: + - alert: Y + expr: up == 0 + - alert: N + expr: up == 1 +`) + names, err := ExtractPrometheusAlertNames(crd) + require.NoError(t, err) + require.Len(t, names, 2) + assert.Equal(t, "g - Y", names[0].CheckRuleName()) + assert.Equal(t, "g - N", names[1].CheckRuleName()) +} + +func TestPrometheusRuleEndpoints_AlertingOnly(t *testing.T) { + crd := []byte(`apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: alerting-only +spec: + groups: + - name: group-a + rules: + - alert: HighErrorRate + expr: errors > 0 +`) + hasAlerts, hasRecords, err := PrometheusRuleEndpoints(crd) + require.NoError(t, err) + assert.True(t, hasAlerts) + assert.False(t, hasRecords) +} + +func TestPrometheusRuleEndpoints_RecordingOnly(t *testing.T) { + crd := []byte(`apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: recording-only +spec: + groups: + - name: group-a + rules: + - record: instance:cpu:avg + expr: avg(cpu) +`) + hasAlerts, hasRecords, err := PrometheusRuleEndpoints(crd) + require.NoError(t, err) + assert.False(t, hasAlerts) + assert.True(t, hasRecords) +} + +func TestPrometheusRuleEndpoints_Mixed(t *testing.T) { + crd := []byte(`apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: mixed +spec: + groups: + - name: group-a + rules: + - alert: HighErrorRate + expr: errors > 0 + - record: instance:cpu:avg + expr: avg(cpu) +`) + hasAlerts, hasRecords, err := PrometheusRuleEndpoints(crd) + require.NoError(t, err) + assert.True(t, hasAlerts) + assert.True(t, hasRecords) +} + +func TestPrometheusRuleEndpoints_NonPrometheusRuleKind(t *testing.T) { + doc := []byte(`kind: CheckRule +id: some-id +name: High Error Rate +expression: up == 0 +`) + hasAlerts, hasRecords, err := PrometheusRuleEndpoints(doc) + require.NoError(t, err) + assert.False(t, hasAlerts) + assert.False(t, hasRecords) +} + func TestParseCheckRules_PlainCheckRuleKeepsName(t *testing.T) { doc := []byte(`kind: CheckRule id: b2c3d4e5-6789-01bc-def0-234567890abc diff --git a/internal/asset/spamfilter.go b/internal/asset/spamfilter.go index f25adeef..1fbc7206 100644 --- a/internal/asset/spamfilter.go +++ b/internal/asset/spamfilter.go @@ -59,6 +59,28 @@ func DetectSpamFilterAPIVersion(data []byte) (string, error) { } } +// SpamFilterUsesOrigin reports whether a spam filter document (v1alpha1 or +// v1alpha2 — both share the same metadata/labels shape) carries a non-empty +// dash0.com/origin label. +// +// --since uses this to warn when a spam filter is about to be deleted by +// dash0.com/id alone: per ImportSpamFilter's upsert-key selection, an +// ID-only spam filter's id is reassigned server-side on its first PUT to a +// brand-new id, so the id recorded in the git history --since diffs against +// may no longer match the live asset's actual id by the time the document +// is deleted — deleting by that stale id either 404s (hard-failing without +// --force) or is silently treated as already-deleted (with --force), +// leaving the real live filter orphaned either way. There is no local, +// API-free way to recover the live id from git history alone; the origin +// label is the only identifier that never gets reassigned. +func SpamFilterUsesOrigin(data []byte) (bool, error) { + var filter dash0api.SpamFilter + if err := sigsyaml.Unmarshal(data, &filter); err != nil { + return false, fmt.Errorf("failed to decode Dash0SpamFilter: %w", err) + } + return filter.Metadata.Labels != nil && filter.Metadata.Labels.Dash0Comorigin != nil && *filter.Metadata.Labels.Dash0Comorigin != "", nil +} + // ImportSpamFilter creates or updates a v1alpha1 spam filter via the standard // CRUD APIs. // diff --git a/internal/asset/spamfilter_test.go b/internal/asset/spamfilter_test.go new file mode 100644 index 00000000..3141bc93 --- /dev/null +++ b/internal/asset/spamfilter_test.go @@ -0,0 +1,63 @@ +package asset + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSpamFilterUsesOrigin_WithOrigin(t *testing.T) { + doc := []byte(`apiVersion: v1alpha2 +kind: Dash0SpamFilter +metadata: + name: Drop debug logs + labels: + dash0.com/id: spam-id + dash0.com/origin: spam-origin +spec: + context: log + filter: + - key: otel.log.severity.range + operator: is + value: DEBUG +`) + usesOrigin, err := SpamFilterUsesOrigin(doc) + require.NoError(t, err) + assert.True(t, usesOrigin) +} + +func TestSpamFilterUsesOrigin_IDOnly(t *testing.T) { + doc := []byte(`apiVersion: v1alpha1 +kind: Dash0SpamFilter +metadata: + name: Drop noisy health checks + labels: + dash0.com/id: spam-id +spec: + contexts: + - log + filter: + - key: http.target + operator: ends_with + value: /healthz +`) + usesOrigin, err := SpamFilterUsesOrigin(doc) + require.NoError(t, err) + assert.False(t, usesOrigin) +} + +func TestSpamFilterUsesOrigin_EmptyOriginLabel(t *testing.T) { + doc := []byte(`kind: Dash0SpamFilter +metadata: + name: x + labels: + dash0.com/id: spam-id + dash0.com/origin: "" +spec: + contexts: [log] +`) + usesOrigin, err := SpamFilterUsesOrigin(doc) + require.NoError(t, err) + assert.False(t, usesOrigin, "an empty-string origin label must not count as using origin") +} diff --git a/internal/git/diff.go b/internal/git/diff.go new file mode 100644 index 00000000..ccfbe78d --- /dev/null +++ b/internal/git/diff.go @@ -0,0 +1,154 @@ +package git + +import ( + "sort" + + dash0yaml "github.com/dash0hq/dash0-api-client-go/yaml" +) + +// Deletion is one asset --since determined must be deleted: its identifier +// was present in the "before" Snapshot (git, at ) and is absent from +// the "after" Snapshot (current disk contents). +type Deletion struct { + Kind string + Identifier string + // Path is the path the asset was found at in the "before" snapshot, + // kept for diagnostic/logging purposes only — deletion is dispatched by + // (Kind, Identifier), never by path. + Path string + // PrometheusRuleEndpoints records which Dash0 endpoint(s) the deleted + // CRD actually used, when Kind is "prometheusrule" (zero value for + // every other kind). The delete dispatch uses this to only call the + // endpoint(s) the CRD used, instead of blind-deleting from both and + // tolerating a 404 from whichever wasn't used — which would otherwise + // silently delete an unrelated asset that happens to share the same + // identifier on the endpoint the CRD never used. + PrometheusRuleEndpoints PrometheusRuleEndpoints + // SpamFilterUsesOrigin records whether the deleted spam filter carried a + // dash0.com/origin label, when Kind is "spamfilter" (meaningless for + // every other kind). false means the filter was identified by + // dash0.com/id alone — its live id may have been reassigned server-side + // since this identifier was recorded, so the delete dispatch warns + // rather than deleting silently. + SpamFilterUsesOrigin bool +} + +// AlertDeletion is a single PrometheusRule alerting rule that disappeared +// from a CRD that otherwise still exists (its own CRD-level identifier is +// present in both snapshots). Detected by (group, alert) name, since the +// CRD's shared identifier cannot distinguish between the alerts it contains. +type AlertDeletion struct { + // CRDIdentifier is the surviving CRD's own identifier, informational + // only — dispatch resolves the check rule to delete by name (see + // PrometheusAlertName.CheckRuleName), not by this identifier. + CRDIdentifier string + dash0yaml.PrometheusAlertName +} + +// DeletionPlan is the result of diffing two Snapshots: everything --since +// determined must be deleted, plus the set of deleted documents that had no +// stable identifier at all (which must fail the whole run rather than be +// silently skipped or silently applied). +type DeletionPlan struct { + ByIdentifier []Deletion + AlertsByName []AlertDeletion + NoIdentifier []string +} + +// IsEmpty reports whether the plan calls for no deletions and has no +// no-identifier failures to surface. +func (p DeletionPlan) IsEmpty() bool { + return len(p.ByIdentifier) == 0 && len(p.AlertsByName) == 0 && len(p.NoIdentifier) == 0 +} + +// Diff compares before (the Snapshot at ) against after (the Snapshot +// of current disk contents) and returns everything that must be deleted. +// This is a pure two-point comparison — an asset created and deleted again +// between and now is invisible to it, by design (see design.md). +func Diff(before, after Snapshot) DeletionPlan { + var plan DeletionPlan + + for key, path := range before.Identifiers { + if _, stillPresent := after.Identifiers[key]; stillPresent { + continue + } + plan.ByIdentifier = append(plan.ByIdentifier, Deletion{ + Kind: key.Kind, + Identifier: key.Identifier, + Path: path, + PrometheusRuleEndpoints: before.PrometheusRuleEndpointsByIdentifier[key.Identifier], + SpamFilterUsesOrigin: before.SpamFilterUsesOriginByIdentifier[key.Identifier], + }) + } + sort.Slice(plan.ByIdentifier, func(i, j int) bool { + if plan.ByIdentifier[i].Kind != plan.ByIdentifier[j].Kind { + return plan.ByIdentifier[i].Kind < plan.ByIdentifier[j].Kind + } + return plan.ByIdentifier[i].Identifier < plan.ByIdentifier[j].Identifier + }) + + for identifier, beforeAlerts := range before.PrometheusAlertsByIdentifier { + afterAlerts, crdSurvives := after.PrometheusAlertsByIdentifier[identifier] + if !crdSurvives { + // Whole CRD deletion is already covered by the Identifiers loop + // above. + continue + } + afterSet := make(map[dash0yaml.PrometheusAlertName]bool, len(afterAlerts)) + for _, name := range afterAlerts { + afterSet[name] = true + } + for _, name := range beforeAlerts { + if !afterSet[name] { + plan.AlertsByName = append(plan.AlertsByName, AlertDeletion{ + CRDIdentifier: identifier, + PrometheusAlertName: name, + }) + } + } + } + sort.Slice(plan.AlertsByName, func(i, j int) bool { + a, b := plan.AlertsByName[i], plan.AlertsByName[j] + if a.CRDIdentifier != b.CRDIdentifier { + return a.CRDIdentifier < b.CRDIdentifier + } + return a.CheckRuleName() < b.CheckRuleName() + }) + + // A no-identifier document carries no id/origin to track across + // snapshots, so file existence is the finest-grained signal available — + // but that signal must be file-existence-AND-count, not file-existence + // alone: a no-identifier document removed from a multi-document file + // that otherwise survives is just as much a deletion design.md says must + // never be silently skipped as one whose whole file disappeared. + beforeDocPathsByFile := map[string][]string{} + for docPath, doc := range before.NoIdentifier { + beforeDocPathsByFile[doc.FilePath] = append(beforeDocPathsByFile[doc.FilePath], docPath) + } + afterCountByFile := map[string]int{} + for _, doc := range after.NoIdentifier { + afterCountByFile[doc.FilePath]++ + } + for filePath, docPaths := range beforeDocPathsByFile { + sort.Strings(docPaths) + if !after.Paths[filePath] { + // The whole file is gone; every no-identifier document in it + // counts as deleted. + plan.NoIdentifier = append(plan.NoIdentifier, docPaths...) + continue + } + survivingCount := afterCountByFile[filePath] + if survivingCount < len(docPaths) { + // Some no-identifier document(s) vanished from this + // otherwise-surviving file. Which exact document(s) can't be + // known — no-identifier documents have nothing to correlate by + // besides file path and count — so the trailing docPaths (by + // the file's original doc-index order) are reported as a + // deterministic, stable choice. + plan.NoIdentifier = append(plan.NoIdentifier, docPaths[survivingCount:]...) + } + } + sort.Strings(plan.NoIdentifier) + + return plan +} diff --git a/internal/git/diff_test.go b/internal/git/diff_test.go new file mode 100644 index 00000000..d36c3635 --- /dev/null +++ b/internal/git/diff_test.go @@ -0,0 +1,213 @@ +package git + +import ( + "testing" + + dash0yaml "github.com/dash0hq/dash0-api-client-go/yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiff_WholeFileDeletion(t *testing.T) { + before := newSnapshot() + before.Identifiers[IdentifierKey{Kind: "dashboard", Identifier: "id-1"}] = "dashboard.yaml" + before.Paths["dashboard.yaml"] = true + + after := newSnapshot() + + plan := Diff(before, after) + require.Len(t, plan.ByIdentifier, 1) + assert.Equal(t, Deletion{Kind: "dashboard", Identifier: "id-1", Path: "dashboard.yaml"}, plan.ByIdentifier[0]) + assert.Empty(t, plan.AlertsByName) + assert.Empty(t, plan.NoIdentifier) + assert.False(t, plan.IsEmpty()) +} + +func TestDiff_NoChangeWhenIdentifierSurvives(t *testing.T) { + before := newSnapshot() + before.Identifiers[IdentifierKey{Kind: "dashboard", Identifier: "id-1"}] = "dashboard.yaml" + + after := newSnapshot() + after.Identifiers[IdentifierKey{Kind: "dashboard", Identifier: "id-1"}] = "renamed.yaml" + + plan := Diff(before, after) + assert.True(t, plan.IsEmpty(), "identifier survived under a different path — not a deletion") +} + +func TestDiff_PrometheusAlertPartialRemoval(t *testing.T) { + before := newSnapshot() + before.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules.yaml" + before.PrometheusAlertsByIdentifier["crd-1"] = []dash0yaml.PrometheusAlertName{ + {GroupName: "g", AlertName: "A"}, + {GroupName: "g", AlertName: "B"}, + } + + after := newSnapshot() + after.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules.yaml" + after.PrometheusAlertsByIdentifier["crd-1"] = []dash0yaml.PrometheusAlertName{ + {GroupName: "g", AlertName: "A"}, + } + + plan := Diff(before, after) + assert.Empty(t, plan.ByIdentifier, "CRD survives, so it is not a whole-file deletion") + require.Len(t, plan.AlertsByName, 1) + assert.Equal(t, "crd-1", plan.AlertsByName[0].CRDIdentifier) + assert.Equal(t, "g - B", plan.AlertsByName[0].CheckRuleName()) +} + +func TestDiff_PrometheusWholeCRDDeletionSkipsAlertCheck(t *testing.T) { + before := newSnapshot() + before.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules.yaml" + before.PrometheusAlertsByIdentifier["crd-1"] = []dash0yaml.PrometheusAlertName{ + {GroupName: "g", AlertName: "A"}, + } + + after := newSnapshot() + + plan := Diff(before, after) + require.Len(t, plan.ByIdentifier, 1) + assert.Equal(t, "prometheusrule", plan.ByIdentifier[0].Kind) + assert.Empty(t, plan.AlertsByName, "whole-CRD deletion must not also be reported as an alert removal") +} + +func TestDiff_NoIdentifierFileDeleted(t *testing.T) { + before := newSnapshot() + before.NoIdentifier["orphan.yaml"] = NoIdentifierDoc{Kind: "dashboard", FilePath: "orphan.yaml"} + before.Paths["orphan.yaml"] = true + + after := newSnapshot() + + plan := Diff(before, after) + require.Len(t, plan.NoIdentifier, 1) + assert.Equal(t, "orphan.yaml", plan.NoIdentifier[0]) +} + +func TestDiff_NoIdentifierFileSurvivesIsNotADeletion(t *testing.T) { + before := newSnapshot() + before.NoIdentifier["orphan.yaml"] = NoIdentifierDoc{Kind: "dashboard", FilePath: "orphan.yaml"} + before.Paths["orphan.yaml"] = true + + after := newSnapshot() + // The document itself survives, not just the file path — this is what a + // real BuildSnapshotFromRef/BuildSnapshotFromDisk pass would produce for + // an untouched no-identifier document. + after.NoIdentifier["orphan.yaml"] = NoIdentifierDoc{Kind: "dashboard", FilePath: "orphan.yaml"} + after.Paths["orphan.yaml"] = true + + plan := Diff(before, after) + assert.True(t, plan.IsEmpty()) +} + +func TestDiff_NoIdentifierMultiDocumentUsesFilePathNotDocPath(t *testing.T) { + // Regression test: a no-identifier document that is the 2nd+ document in + // a multi-document file is keyed as "file.yaml#1" in one snapshot, but + // could just as well be keyed "file.yaml" (index 0) in the other if the + // file's document order shifted — e.g. an earlier document in the same + // file was removed, re-indexing everything after it. The document itself + // still survives, just under a different docPath, so this must not be + // misreported as a deletion; Diff's per-file document *count* comparison + // (not exact docPath string matching) is what makes that work. + before := newSnapshot() + before.NoIdentifier["combined.yaml#1"] = NoIdentifierDoc{Kind: "dashboard", FilePath: "combined.yaml"} + before.Paths["combined.yaml"] = true + + after := newSnapshot() + after.NoIdentifier["combined.yaml"] = NoIdentifierDoc{Kind: "dashboard", FilePath: "combined.yaml"} + after.Paths["combined.yaml"] = true + + plan := Diff(before, after) + assert.True(t, plan.IsEmpty()) +} + +// TestDiff_NoIdentifierDocRemovedFromSurvivingMultiDocFile is a regression +// test for a bug where a no-identifier document removed from a file that +// otherwise survives produced no signal at all: Diff's old check only +// compared file existence (after.Paths[doc.FilePath]), so as long as the +// file was still there — regardless of how many of its documents remained — +// nothing was ever reported. design.md's invariant is that a disappearing +// no-identifier document must always fail the run loudly, so a per-file +// count comparison is required, not just existence. +func TestDiff_NoIdentifierDocRemovedFromSurvivingMultiDocFile(t *testing.T) { + before := newSnapshot() + // Two no-identifier documents in the same file, plus one identified View + // that survives untouched. + before.NoIdentifier["combined.yaml"] = NoIdentifierDoc{Kind: "dashboard", FilePath: "combined.yaml"} + before.NoIdentifier["combined.yaml#1"] = NoIdentifierDoc{Kind: "dashboard", FilePath: "combined.yaml"} + before.Identifiers[IdentifierKey{Kind: "view", Identifier: "keep-id"}] = "combined.yaml#2" + before.Paths["combined.yaml"] = true + + after := newSnapshot() + // Only one no-identifier document survives; the View survives too. + after.NoIdentifier["combined.yaml"] = NoIdentifierDoc{Kind: "dashboard", FilePath: "combined.yaml"} + after.Identifiers[IdentifierKey{Kind: "view", Identifier: "keep-id"}] = "combined.yaml#1" + after.Paths["combined.yaml"] = true + + plan := Diff(before, after) + require.Len(t, plan.NoIdentifier, 1, "one of the two no-identifier documents vanished from the surviving file and must be reported") + assert.Equal(t, "combined.yaml#1", plan.NoIdentifier[0]) + assert.Empty(t, plan.ByIdentifier, "the surviving View must not be affected") +} + +func TestDiff_EmptyWhenBothSnapshotsEmpty(t *testing.T) { + plan := Diff(newSnapshot(), newSnapshot()) + assert.True(t, plan.IsEmpty()) +} + +func TestDiff_ByIdentifierSortOrder(t *testing.T) { + // Four deletions across three kinds, with two identifiers sharing the + // same "dashboard" kind, so the sort must break ties on Identifier + // rather than stopping at the Kind comparison. Map iteration order is + // randomized on every run, so this alone is enough to exercise both + // branches of the comparator regardless of insertion order. + before := newSnapshot() + before.Identifiers[IdentifierKey{Kind: "view", Identifier: "z"}] = "view.yaml" + before.Identifiers[IdentifierKey{Kind: "dashboard", Identifier: "b"}] = "dashboard-b.yaml" + before.Identifiers[IdentifierKey{Kind: "dashboard", Identifier: "a"}] = "dashboard-a.yaml" + before.Identifiers[IdentifierKey{Kind: "checkrule", Identifier: "m"}] = "checkrule.yaml" + + after := newSnapshot() + + plan := Diff(before, after) + require.Len(t, plan.ByIdentifier, 4) + assert.Equal(t, []Deletion{ + {Kind: "checkrule", Identifier: "m", Path: "checkrule.yaml"}, + {Kind: "dashboard", Identifier: "a", Path: "dashboard-a.yaml"}, + {Kind: "dashboard", Identifier: "b", Path: "dashboard-b.yaml"}, + {Kind: "view", Identifier: "z", Path: "view.yaml"}, + }, plan.ByIdentifier) +} + +func TestDiff_AlertsByNameSortOrder(t *testing.T) { + // Two surviving CRDs, each losing alerts, with "crd-2" losing two so the + // sort must break ties on CheckRuleName within the same CRDIdentifier. + before := newSnapshot() + before.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules1.yaml" + before.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-2"}] = "rules2.yaml" + before.PrometheusAlertsByIdentifier["crd-1"] = []dash0yaml.PrometheusAlertName{ + {GroupName: "g", AlertName: "B"}, + {GroupName: "g", AlertName: "A"}, + } + before.PrometheusAlertsByIdentifier["crd-2"] = []dash0yaml.PrometheusAlertName{ + {GroupName: "g", AlertName: "Z"}, + {GroupName: "g", AlertName: "A"}, + } + + after := newSnapshot() + after.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules1.yaml" + after.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-2"}] = "rules2.yaml" + after.PrometheusAlertsByIdentifier["crd-1"] = []dash0yaml.PrometheusAlertName{ + {GroupName: "g", AlertName: "A"}, + } + after.PrometheusAlertsByIdentifier["crd-2"] = nil + + plan := Diff(before, after) + require.Len(t, plan.AlertsByName, 3) + names := make([]string, len(plan.AlertsByName)) + crds := make([]string, len(plan.AlertsByName)) + for i, d := range plan.AlertsByName { + names[i] = d.CheckRuleName() + crds[i] = d.CRDIdentifier + } + assert.Equal(t, []string{"crd-1", "crd-2", "crd-2"}, crds) + assert.Equal(t, []string{"g - B", "g - A", "g - Z"}, names) +} diff --git a/internal/git/plumbing.go b/internal/git/plumbing.go new file mode 100644 index 00000000..2f51071a --- /dev/null +++ b/internal/git/plumbing.go @@ -0,0 +1,159 @@ +// Package git wraps the git plumbing commands `--since` needs +// (rev-parse, merge-base, cat-file, ls-tree) via the system git binary, +// never porcelain commands (git diff, git show, git log) — porcelain output +// is for human consumption and isn't a stable, documented contract across +// git versions. +package git + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "sort" + "strings" + + "github.com/dash0hq/dash0-cli/internal/asset" +) + +// AllZerosSHA is git's sentinel for "this ref did not exist" — the value +// GitHub gives github.event.before on a branch's first push, and the value +// git's own pre-receive/post-receive hooks use for a created/deleted ref. +const AllZerosSHA = "0000000000000000000000000000000000000000" + +// ErrRefNotFound is returned by resolveCommit when git could not resolve a +// ref to a commit — a nonexistent ref, a too-shallow history, or any other +// plain git-resolution failure. It is not returned for infrastructure +// failures (git binary missing, context canceled), which propagate as-is. +var ErrRefNotFound = errors.New("git ref not found") + +// Repo is a lightweight handle to a git working tree, used to run plumbing +// commands against it via the system git binary. Dir may be the working +// tree's root or any directory inside it — git -C resolves it either way. +type Repo struct { + Dir string +} + +// run invokes `git -C ` and returns stdout on success. A +// non-zero exit still returns an error usable with errors.As(&exitErr) to +// distinguish "git said no" from an infrastructure failure (binary missing, +// context canceled) — the wrapping here uses %w specifically to preserve +// that distinction for callers further up the chain. +func (r Repo) run(ctx context.Context, args ...string) ([]byte, error) { + fullArgs := append([]string{"-C", r.Dir}, args...) + cmd := exec.CommandContext(ctx, "git", fullArgs...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("git %s: %w (stderr: %s)", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) + } + return stdout.Bytes(), nil +} + +// resolveCommit runs `git rev-parse --verify ^{commit}`, returning the +// resolved commit SHA. Returns ErrRefNotFound when git exits non-zero +// because the ref itself doesn't resolve (as opposed to an infrastructure +// failure, which is returned unwrapped). +func (r Repo) resolveCommit(ctx context.Context, ref string) (string, error) { + out, err := r.run(ctx, "rev-parse", "--verify", ref+"^{commit}") + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", ErrRefNotFound + } + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// IsAncestor runs `git merge-base --is-ancestor `, +// reporting whether ancestor is reachable from descendant. Per git's own +// documented convention for --is-ancestor, exit 0 means true and exit 1 +// means false; any other outcome is a genuine error (e.g. one of the refs +// doesn't exist). +func (r Repo) IsAncestor(ctx context.Context, ancestor, descendant string) (bool, error) { + _, err := r.run(ctx, "merge-base", "--is-ancestor", ancestor, descendant) + if err == nil { + return true, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return false, nil + } + return false, fmt.Errorf("failed to check ancestry of %s against %s: %w", ancestor, descendant, err) +} + +// Root runs `git rev-parse --show-toplevel`, returning the absolute path to +// the repository root containing r.Dir. Callers use this to anchor +// repo-relative paths consistently between BuildSnapshotFromRef (whose paths +// are always repo-root-relative, per git ls-tree's own behavior) and +// BuildSnapshotFromDisk. +func (r Repo) Root(ctx context.Context) (string, error) { + out, err := r.run(ctx, "rev-parse", "--show-toplevel") + if err != nil { + return "", fmt.Errorf("failed to determine repository root for %s: %w", r.Dir, err) + } + return strings.TrimSpace(string(out)), nil +} + +// ReadFileAtRef runs `git cat-file -p :`, returning the file's +// content at that ref. path must be relative to the repo root, using +// forward slashes (git's own path convention). +func (r Repo) ReadFileAtRef(ctx context.Context, ref, path string) ([]byte, error) { + out, err := r.run(ctx, "cat-file", "-p", ref+":"+path) + if err != nil { + return nil, fmt.Errorf("failed to read %s at %s: %w", path, ref, err) + } + return out, nil +} + +// ListYAMLFilesAtRef runs `git ls-tree -r --name-only [-- ]`, +// returning every .yaml/.yml file at that ref within scope (a repo-relative +// directory or file path; empty scope lists the whole tree). Hidden files +// and directories (any path component starting with ".") are skipped, +// matching apply's existing discoverFiles behavior for disk scans, so the +// git-side and disk-side listings stay consistent — including exempting the +// scope itself from the hidden check: a dot-prefixed -f target (e.g. +// -f .dash0-assets/) is a deliberate user choice, not something to skip, the +// same way FindNonHiddenYAMLFiles never applies IsHiddenPath to its walk +// root. Every path component *inside* scope is still checked normally. +// +// The .yaml/.yml extension check is likewise skipped when scope names a +// single file exactly (line == scope; a directory scope's entries are always +// listed as scope/, never scope itself, so this can only match a +// genuine single-file target) — apply's own single-file create/update path +// (readMultiDocumentYAML) has no extension check at all, so -f config.json +// must be scanned by --since the same way it's read by every other apply +// path, not silently excluded from both snapshots because of its extension. +func (r Repo) ListYAMLFilesAtRef(ctx context.Context, ref, scope string) ([]string, error) { + args := []string{"ls-tree", "-r", "--name-only", ref} + if scope != "" { + args = append(args, "--", scope) + } + out, err := r.run(ctx, args...) + if err != nil { + return nil, fmt.Errorf("failed to list files at %s: %w", ref, err) + } + + var files []string + for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + isExplicitSingleFileTarget := scope != "" && line == scope + if !isExplicitSingleFileTarget && !asset.IsYAMLFile(line) { + continue + } + pathBelowScope := strings.TrimPrefix(line, scope) + pathBelowScope = strings.TrimPrefix(pathBelowScope, "/") + if asset.IsHiddenPath(pathBelowScope) { + continue + } + files = append(files, line) + } + sort.Strings(files) + return files, nil +} diff --git a/internal/git/plumbing_test.go b/internal/git/plumbing_test.go new file mode 100644 index 00000000..611e1aa1 --- /dev/null +++ b/internal/git/plumbing_test.go @@ -0,0 +1,136 @@ +package git + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListYAMLFilesAtRef_SkipsHiddenAndNonYAML(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "dashboard.yaml", "kind: Dashboard\n") + writeFile(t, repo.Dir, "notes.txt", "not yaml\n") + writeFile(t, repo.Dir, ".hidden/secret.yaml", "kind: Dashboard\n") + writeFile(t, repo.Dir, "nested/view.yml", "kind: View\n") + commitAll(t, repo.Dir, "add files") + + files, err := repo.ListYAMLFilesAtRef(context.Background(), "HEAD", "") + require.NoError(t, err) + assert.Equal(t, []string{"dashboard.yaml", "nested/view.yml"}, files) +} + +func TestListYAMLFilesAtRef_ScopedToDirectory(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "a/dashboard.yaml", "kind: Dashboard\n") + writeFile(t, repo.Dir, "b/view.yaml", "kind: View\n") + commitAll(t, repo.Dir, "add files") + + files, err := repo.ListYAMLFilesAtRef(context.Background(), "HEAD", "a") + require.NoError(t, err) + assert.Equal(t, []string{"a/dashboard.yaml"}, files) +} + +// TestListYAMLFilesAtRef_SingleFileScopeIgnoresExtension is a regression +// test for a bug where a single-file -f target without a .yaml/.yml +// extension (e.g. -f config.json) was silently excluded from the git-ref +// side of a --since scan, even though apply's own single-file create/update +// path (readMultiDocumentYAML) has no extension check at all and would read +// the exact same file just fine. +func TestListYAMLFilesAtRef_SingleFileScopeIgnoresExtension(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "config.json", "kind: Dashboard\n") + commitAll(t, repo.Dir, "add config.json") + + files, err := repo.ListYAMLFilesAtRef(context.Background(), "HEAD", "config.json") + require.NoError(t, err) + assert.Equal(t, []string{"config.json"}, files, "a single-file scope must be scanned regardless of its extension") +} + +// TestListYAMLFilesAtRef_DirectoryScopeStillFiltersExtension confirms the +// fix above didn't turn off extension filtering for directory scopes: only +// an *exact* scope match (a single-file target) is exempt. +func TestListYAMLFilesAtRef_DirectoryScopeStillFiltersExtension(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "assets/dashboard.yaml", "kind: Dashboard\n") + writeFile(t, repo.Dir, "assets/notes.txt", "not yaml\n") + commitAll(t, repo.Dir, "add files") + + files, err := repo.ListYAMLFilesAtRef(context.Background(), "HEAD", "assets") + require.NoError(t, err) + assert.Equal(t, []string{"assets/dashboard.yaml"}, files) +} + +// TestListYAMLFilesAtRef_ScopedToDotPrefixedDirectory is a regression test +// for a bug where scoping to a dot-prefixed directory (e.g. -f .dash0-assets/) +// always returned zero files: IsHiddenPath was applied to the full +// scope-prefixed repo-relative path, so the scope directory's own leading +// "." made every file inside it look hidden. The disk-side walker +// (FindNonHiddenYAMLFiles) exempts its walk root from the hidden check the +// same way; the git-side listing must match that, checking hidden-ness only +// for path components *below* scope. +func TestListYAMLFilesAtRef_ScopedToDotPrefixedDirectory(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, ".dash0-assets/dashboard.yaml", "kind: Dashboard\n") + writeFile(t, repo.Dir, ".dash0-assets/.hidden/nested.yaml", "kind: View\n") + commitAll(t, repo.Dir, "add files") + + files, err := repo.ListYAMLFilesAtRef(context.Background(), "HEAD", ".dash0-assets") + require.NoError(t, err) + assert.Equal(t, []string{".dash0-assets/dashboard.yaml"}, files, "the dot-prefixed scope itself must not hide its own contents, but a hidden directory nested inside it still must be skipped") +} + +func TestReadFileAtRef(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "dashboard.yaml", "kind: Dashboard\nname: v1\n") + commitAll(t, repo.Dir, "v1") + writeFile(t, repo.Dir, "dashboard.yaml", "kind: Dashboard\nname: v2\n") + sha2 := commitAll(t, repo.Dir, "v2") + + content, err := repo.ReadFileAtRef(context.Background(), "HEAD~1", "dashboard.yaml") + require.NoError(t, err) + assert.Equal(t, "kind: Dashboard\nname: v1\n", string(content)) + + content, err = repo.ReadFileAtRef(context.Background(), sha2, "dashboard.yaml") + require.NoError(t, err) + assert.Equal(t, "kind: Dashboard\nname: v2\n", string(content)) +} + +func TestIsAncestor(t *testing.T) { + repo := testRepo(t) + base := runGit(t, repo.Dir, "rev-parse", "HEAD") + writeFile(t, repo.Dir, "f.yaml", "kind: Dashboard\n") + head := commitAll(t, repo.Dir, "add file") + + isAncestor, err := repo.IsAncestor(context.Background(), base, head) + require.NoError(t, err) + assert.True(t, isAncestor) + + isAncestor, err = repo.IsAncestor(context.Background(), head, base) + require.NoError(t, err) + assert.False(t, isAncestor) +} + +func TestRoot(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "sub/dashboard.yaml", "kind: Dashboard\n") + commitAll(t, repo.Dir, "add file") + + wantRoot, err := filepath.EvalSymlinks(repo.Dir) + require.NoError(t, err) + + subRepo := Repo{Dir: repo.Dir + "/sub"} + root, err := subRepo.Root(context.Background()) + require.NoError(t, err) + gotRoot, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + assert.Equal(t, wantRoot, gotRoot, "Root must resolve to the repo's top level even when Dir is a subdirectory") +} + +func TestIsAncestor_UnknownRefIsError(t *testing.T) { + repo := testRepo(t) + _, err := repo.IsAncestor(context.Background(), "does-not-exist", "HEAD") + require.Error(t, err) +} diff --git a/internal/git/ref.go b/internal/git/ref.go new file mode 100644 index 00000000..e9172db2 --- /dev/null +++ b/internal/git/ref.go @@ -0,0 +1,69 @@ +package git + +import "context" + +// RefState classifies a `--since` ref before any deletion detection runs. +// It is a named string type, rather than an int paired with a separate +// Stringer, so each constant's declaration doubles as its own printable +// representation (in log lines, test failure output, etc.) — there is no +// parallel name-mapping switch that could drift out of sync. +type RefState string + +const ( + // RefEmpty means the ref was the empty string — --since was not passed, + // or was passed as "". + RefEmpty RefState = "RefEmpty" + // RefAllZeros means the ref was git's all-zeros sentinel (AllZerosSHA) — + // e.g. GitHub's github.event.before on a branch's first push. There is + // no "before" state to compare against. + RefAllZeros RefState = "RefAllZeros" + // RefResolvedAncestor means the ref resolved to a real commit that is an + // ancestor of HEAD — the ordinary, expected case. + RefResolvedAncestor RefState = "RefResolvedAncestor" + // RefResolvedNonAncestor means the ref resolved to a real commit, but + // that commit is not an ancestor of HEAD (e.g. after a force-push or + // history rewrite). Callers must not silently treat this like the + // ancestor case. + RefResolvedNonAncestor RefState = "RefResolvedNonAncestor" + // RefUnresolvable means git could not resolve the ref to a commit at all + // (typo, too-shallow clone, ref genuinely doesn't exist). + RefUnresolvable RefState = "RefUnresolvable" +) + +// ClassifyRef resolves ref against repo and classifies it into a RefState. +// resolvedSHA is populated only for RefResolvedAncestor and +// RefResolvedNonAncestor; it is the commit --since's two-point diff should +// read the "before" state from. +// +// err is reserved for infrastructure failures (git binary missing, context +// canceled, HEAD itself unresolvable) — a ref that simply doesn't resolve is +// not an error, it's the RefUnresolvable state. +func (r Repo) ClassifyRef(ctx context.Context, ref string) (state RefState, resolvedSHA string, err error) { + if ref == "" { + return RefEmpty, "", nil + } + if ref == AllZerosSHA { + return RefAllZeros, "", nil + } + + sha, resolveErr := r.resolveCommit(ctx, ref) + if resolveErr != nil { + if isRefNotFound(resolveErr) { + return RefUnresolvable, "", nil + } + return RefUnresolvable, "", resolveErr + } + + isAncestor, ancestorErr := r.IsAncestor(ctx, sha, "HEAD") + if ancestorErr != nil { + return RefUnresolvable, "", ancestorErr + } + if isAncestor { + return RefResolvedAncestor, sha, nil + } + return RefResolvedNonAncestor, sha, nil +} + +func isRefNotFound(err error) bool { + return err == ErrRefNotFound +} diff --git a/internal/git/ref_test.go b/internal/git/ref_test.go new file mode 100644 index 00000000..664e2231 --- /dev/null +++ b/internal/git/ref_test.go @@ -0,0 +1,81 @@ +package git + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClassifyRef_Empty(t *testing.T) { + repo := testRepo(t) + state, sha, err := repo.ClassifyRef(context.Background(), "") + require.NoError(t, err) + assert.Equal(t, RefEmpty, state) + assert.Empty(t, sha) +} + +func TestClassifyRef_AllZeros(t *testing.T) { + repo := testRepo(t) + state, sha, err := repo.ClassifyRef(context.Background(), AllZerosSHA) + require.NoError(t, err) + assert.Equal(t, RefAllZeros, state) + assert.Empty(t, sha) +} + +func TestClassifyRef_Unresolvable(t *testing.T) { + repo := testRepo(t) + state, sha, err := repo.ClassifyRef(context.Background(), "totally-bogus-ref") + require.NoError(t, err) + assert.Equal(t, RefUnresolvable, state) + assert.Empty(t, sha) +} + +func TestClassifyRef_ResolvedAncestor(t *testing.T) { + repo := testRepo(t) + base := runGit(t, repo.Dir, "rev-parse", "HEAD") + writeFile(t, repo.Dir, "f.yaml", "kind: Dashboard\n") + commitAll(t, repo.Dir, "add file") + + state, sha, err := repo.ClassifyRef(context.Background(), base) + require.NoError(t, err) + assert.Equal(t, RefResolvedAncestor, state) + assert.Equal(t, base, sha) +} + +func TestClassifyRef_ResolvedNonAncestor(t *testing.T) { + repo := testRepo(t) + + // Branch A: diverges from main. + runGit(t, repo.Dir, "checkout", "-q", "-b", "branch-a") + writeFile(t, repo.Dir, "a.yaml", "kind: Dashboard\n") + branchA := commitAll(t, repo.Dir, "branch a commit") + + // main moves on independently, so branchA is not its ancestor. + runGit(t, repo.Dir, "checkout", "-q", "main") + writeFile(t, repo.Dir, "b.yaml", "kind: Dashboard\n") + commitAll(t, repo.Dir, "main commit") + + state, sha, err := repo.ClassifyRef(context.Background(), branchA) + require.NoError(t, err) + assert.Equal(t, RefResolvedNonAncestor, state) + assert.Equal(t, branchA, sha) +} + +// TestRefState_PrintsSymbolicName confirms each constant's declared string +// value doubles as a readable representation in log lines and test failure +// output (fmt.Sprintf("%v", ...) / %s), with no separate Stringer needed. +func TestRefState_PrintsSymbolicName(t *testing.T) { + cases := map[RefState]string{ + RefEmpty: "RefEmpty", + RefAllZeros: "RefAllZeros", + RefResolvedAncestor: "RefResolvedAncestor", + RefResolvedNonAncestor: "RefResolvedNonAncestor", + RefUnresolvable: "RefUnresolvable", + } + for state, want := range cases { + assert.Equal(t, want, fmt.Sprintf("%v", state)) + } +} diff --git a/internal/git/snapshot.go b/internal/git/snapshot.go new file mode 100644 index 00000000..fac5b8c8 --- /dev/null +++ b/internal/git/snapshot.go @@ -0,0 +1,285 @@ +package git + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/dash0hq/dash0-cli/internal/asset" + dash0yaml "github.com/dash0hq/dash0-api-client-go/yaml" + "gopkg.in/yaml.v3" +) + +// IdentifierKey uniquely identifies one asset across a Snapshot — kind plus +// its upsert identifier (id or origin, depending on the kind; see +// asset.ExtractIdentifier). Keying by kind as well as identifier means two +// different asset kinds can never collide even if their identifier strings +// happen to match. +type IdentifierKey struct { + Kind string + Identifier string +} + +// NoIdentifierDoc records a document that carries no stable identifier: its +// kind, and the file path it came from (without any multi-document suffix). +type NoIdentifierDoc struct { + Kind string + FilePath string +} + +// Snapshot is the set of asset identifiers found across a scanned scope (a +// directory or a single file) at one point in time — either a git ref or the +// current disk contents. --since's deletion detection is a diff between two +// Snapshots, never a commit-by-commit history scan. +type Snapshot struct { + // Identifiers maps every document's (kind, identifier) to the + // repo-relative (or scope-relative) path it was found at, for every + // document that carries a stable identifier. + Identifiers map[IdentifierKey]string + + // NoIdentifier maps the doc path (the file path, plus a "#" + // suffix for the second and later documents in a multi-document file) + // of every document with no stable identifier to its details. Diff + // checks FilePath (not the doc path itself) against the other + // snapshot's Paths, since a no-identifier document can only be + // identified as "deleted" by its underlying file disappearing. + NoIdentifier map[string]NoIdentifierDoc + + // PrometheusAlertsByIdentifier maps a PrometheusRule CRD's identifier to + // the (group, alert) pairs it contains, for detecting an individual + // alerting rule removed from a CRD that otherwise still exists. + PrometheusAlertsByIdentifier map[string][]dash0yaml.PrometheusAlertName + + // PrometheusRuleEndpointsByIdentifier maps a PrometheusRule CRD's + // identifier to which Dash0 endpoint(s) it actually uses. Diff carries + // this into Deletion for a whole-CRD deletion, so the delete dispatch + // only calls the endpoint(s) the CRD used — never blind-deleting from + // the other endpoint just because it also happens to 404-tolerate. + PrometheusRuleEndpointsByIdentifier map[string]PrometheusRuleEndpoints + + // SpamFilterUsesOriginByIdentifier maps a spam filter's identifier to + // whether it carries a dash0.com/origin label (per + // asset.SpamFilterUsesOrigin). Diff carries this into Deletion so --since + // can warn when deleting an ID-only spam filter, whose id may have been + // reassigned server-side since this identifier was recorded. + SpamFilterUsesOriginByIdentifier map[string]bool + + // Paths is the set of every file path scanned, regardless of whether it + // parsed into a recognized kind. Used to check whether a NoIdentifier + // document's file still exists at all in the other snapshot. + Paths map[string]bool +} + +// PrometheusRuleEndpoints records which Dash0 endpoint(s) a PrometheusRule +// CRD uses, per internal/asset.PrometheusRuleEndpoints. +type PrometheusRuleEndpoints struct { + HasAlerts bool + HasRecords bool +} + +func newSnapshot() Snapshot { + return Snapshot{ + Identifiers: map[IdentifierKey]string{}, + NoIdentifier: map[string]NoIdentifierDoc{}, + PrometheusAlertsByIdentifier: map[string][]dash0yaml.PrometheusAlertName{}, + PrometheusRuleEndpointsByIdentifier: map[string]PrometheusRuleEndpoints{}, + SpamFilterUsesOriginByIdentifier: map[string]bool{}, + Paths: map[string]bool{}, + } +} + +// BuildSnapshotFromRef builds a Snapshot from the contents of scope (a +// repo-relative directory or file path; "" scans the whole repo) as they +// existed at ref. +func BuildSnapshotFromRef(ctx context.Context, repo Repo, ref, scope string) (Snapshot, error) { + files, err := repo.ListYAMLFilesAtRef(ctx, ref, scope) + if err != nil { + return Snapshot{}, err + } + + snap := newSnapshot() + for _, path := range files { + snap.Paths[path] = true + data, err := repo.ReadFileAtRef(ctx, ref, path) + if err != nil { + return Snapshot{}, err + } + if err := ingestDocuments(&snap, path, data); err != nil { + return Snapshot{}, fmt.Errorf("%s at %s: %w", path, ref, err) + } + } + return snap, nil +} + +// BuildSnapshotFromDisk builds a Snapshot from the current contents of scope +// on disk (an absolute or working-directory-relative directory or file +// path). Hidden files and directories are skipped, matching the git-ref side +// (ListYAMLFilesAtRef) and apply's own discoverFiles behavior. +// +// repoRoot anchors the relative paths recorded in the returned Snapshot: it +// must be the same repository root used to resolve the ref passed to +// BuildSnapshotFromRef, so the two Snapshots' paths line up for Diff's +// NoIdentifier check (git ls-tree always prints paths relative to the repo +// root, regardless of any pathspec scope, so the disk side must match that +// convention rather than being relative to scope itself). +// +// ctx is honored for cancellation between files (checked once per visited +// entry) — this function does no I/O that itself accepts a context today, +// but taking one keeps the signature consistent with the rest of this +// package's public API and forward-compatible with future callers that need +// to bound how long a large directory scan can run. +func BuildSnapshotFromDisk(ctx context.Context, scope, repoRoot string) (Snapshot, error) { + info, err := os.Stat(scope) + if err != nil { + return Snapshot{}, fmt.Errorf("failed to stat %s: %w", scope, err) + } + + snap := newSnapshot() + + ingest := func(path string) error { + if err := ctx.Err(); err != nil { + return err + } + absPath, err := filepath.Abs(path) + if err != nil { + return err + } + relPath, err := filepath.Rel(repoRoot, absPath) + if err != nil { + return fmt.Errorf("failed to compute path relative to repo root %s: %w", repoRoot, err) + } + relPath = filepath.ToSlash(relPath) + snap.Paths[relPath] = true + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read %s: %w", path, err) + } + if err := ingestDocuments(&snap, relPath, data); err != nil { + return fmt.Errorf("%s: %w", relPath, err) + } + return nil + } + + if !info.IsDir() { + // No extension check here: scope is a single file the caller (or the + // user, via -f) named explicitly, and apply's own single-file + // create/update path (readMultiDocumentYAML) has no extension check + // either — a -f config.json target must be scanned by --since the + // same way it's read by every other apply path, not silently + // excluded from the snapshot because of its extension. Matches + // ListYAMLFilesAtRef's equivalent exemption for a single-file scope + // on the git-ref side. + if err := ingest(scope); err != nil { + return Snapshot{}, err + } + return snap, nil + } + + var paths []string + if err := filepath.WalkDir(scope, asset.FindNonHiddenYAMLFiles(scope, &paths, nil)); err != nil { + return Snapshot{}, err + } + for _, path := range paths { + if err := ingest(path); err != nil { + return Snapshot{}, err + } + } + return snap, nil +} + +// ingestDocuments splits data (which may be a multi-document YAML stream) +// and records each document's identifier (or lack thereof) into snap under +// path. Multiple documents in one file are distinguished in NoIdentifier by +// appending a "#" suffix to path for the second and later documents. +func ingestDocuments(snap *Snapshot, path string, data []byte) error { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + index := 0 + for { + var node yaml.Node + err := decoder.Decode(&node) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return fmt.Errorf("failed to parse YAML: %w", err) + } + // Skip empty documents (e.g. a trailing "---" with nothing after + // it), matching apply's readMultiDocumentYAML. + if node.Kind == 0 { + continue + } + + docBytes, err := yaml.Marshal(&node) + if err != nil { + return fmt.Errorf("failed to re-marshal document: %w", err) + } + + docPath := path + if index > 0 { + docPath = fmt.Sprintf("%s#%d", path, index) + } + index++ + + kind, err := dash0yaml.DetectKind(docBytes) + if err != nil { + return fmt.Errorf("failed to detect kind: %w", err) + } + if kind == "" || !asset.IsValidKind(kind) { + // Either no recognizable kind at all, or a kind Dash0 doesn't + // know about (e.g. a stray Kubernetes ConfigMap sitting in a + // scanned scope's git history). apply's own document validation + // already hard-fails on an unsupported kind for the *current* + // contents of -f; --since additionally scans historical content + // the live apply path never looks at, so a since-deleted, + // unrelated document must not abort the whole deletion + // computation just because it isn't a Dash0 asset. + continue + } + + identifier, err := dash0yaml.ExtractIdentifier(docBytes) + if err != nil { + return fmt.Errorf("failed to extract identifier: %w", err) + } + + normalizedKind := asset.NormalizeKind(kind) + if identifier == "" { + snap.NoIdentifier[docPath] = NoIdentifierDoc{Kind: normalizedKind, FilePath: path} + continue + } + snap.Identifiers[IdentifierKey{Kind: normalizedKind, Identifier: identifier}] = docPath + + if normalizedKind == "prometheusrule" { + // asset.ExtractPrometheusAlertNames, not + // dash0yaml.ExtractPrometheusAlertNames: the dash0yaml version + // unmarshals into a *string struct field via sigs.k8s.io/yaml, + // which silently corrupts an alert name that happens to be a + // YAML boolean literal (e.g. "Y", "no") into "true"/"false". + // asset's version reads the raw YAML node value instead. See + // asset.ExtractPrometheusAlertNames's doc comment. + alerts, err := asset.ExtractPrometheusAlertNames(docBytes) + if err != nil { + return fmt.Errorf("failed to extract alert names: %w", err) + } + snap.PrometheusAlertsByIdentifier[identifier] = alerts + + hasAlerts, hasRecords, err := asset.PrometheusRuleEndpoints(docBytes) + if err != nil { + return fmt.Errorf("failed to determine PrometheusRule endpoints: %w", err) + } + snap.PrometheusRuleEndpointsByIdentifier[identifier] = PrometheusRuleEndpoints{HasAlerts: hasAlerts, HasRecords: hasRecords} + } + + if normalizedKind == "spamfilter" { + usesOrigin, err := asset.SpamFilterUsesOrigin(docBytes) + if err != nil { + return fmt.Errorf("failed to determine spam filter identifier source: %w", err) + } + snap.SpamFilterUsesOriginByIdentifier[identifier] = usesOrigin + } + } + return nil +} diff --git a/internal/git/snapshot_test.go b/internal/git/snapshot_test.go new file mode 100644 index 00000000..236a5130 --- /dev/null +++ b/internal/git/snapshot_test.go @@ -0,0 +1,189 @@ +package git + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const dashboardYAML = `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +` + +const checkRuleYAML = `apiVersion: dash0.com/v1alpha1 +kind: CheckRule +id: b2c3d4e5-6789-01bc-def0-234567890abc +name: High Error Rate +expression: up == 0 +` + +const dashboardNoIdentifierYAML = `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: no-id-dashboard +spec: + display: + name: No ID Dashboard +` + +const prometheusRuleYAML = `apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: my-rules + labels: + dash0.com/id: shared-id +spec: + groups: + - name: group-a + rules: + - alert: HighErrorRate + expr: errors > 0 + - alert: DiskFull + expr: disk > 0 +` + +const configMapYAML = `apiVersion: v1 +kind: ConfigMap +metadata: + name: unrelated-configmap +data: + foo: bar +` + +// TestBuildSnapshotFromRef_UnrecognizedKindIsTolerated is a regression test +// for a bug where a document whose kind Dash0 doesn't recognize (e.g. a +// stray Kubernetes ConfigMap sitting in a scanned scope, unrelated to any +// Dash0 asset) hard-failed the whole snapshot build via +// dash0yaml.ExtractIdentifier's "unsupported kind" error, masking whatever +// real Dash0 deletions --since should have detected in the same scope. +func TestBuildSnapshotFromRef_UnrecognizedKindIsTolerated(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "dashboard.yaml", dashboardYAML) + writeFile(t, repo.Dir, "configmap.yaml", configMapYAML) + commitAll(t, repo.Dir, "add assets") + + snap, err := BuildSnapshotFromRef(context.Background(), repo, "HEAD", "") + require.NoError(t, err) + + assert.Equal(t, "dashboard.yaml", snap.Identifiers[IdentifierKey{Kind: "dashboard", Identifier: "a1b2c3d4-5678-90ab-cdef-1234567890ab"}]) + assert.NotContains(t, snap.NoIdentifier, "configmap.yaml", "an unrecognized kind must be silently ignored, not tracked as a no-identifier document") + assert.Len(t, snap.Identifiers, 1) +} + +func TestBuildSnapshotFromRef_BasicIdentifiers(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "dashboard.yaml", dashboardYAML) + writeFile(t, repo.Dir, "checkrule.yaml", checkRuleYAML) + commitAll(t, repo.Dir, "add assets") + + snap, err := BuildSnapshotFromRef(context.Background(), repo, "HEAD", "") + require.NoError(t, err) + + assert.Equal(t, "dashboard.yaml", snap.Identifiers[IdentifierKey{Kind: "dashboard", Identifier: "a1b2c3d4-5678-90ab-cdef-1234567890ab"}]) + assert.Equal(t, "checkrule.yaml", snap.Identifiers[IdentifierKey{Kind: "checkrule", Identifier: "b2c3d4e5-6789-01bc-def0-234567890abc"}]) + assert.True(t, snap.Paths["dashboard.yaml"]) + assert.True(t, snap.Paths["checkrule.yaml"]) + assert.Empty(t, snap.NoIdentifier) +} + +func TestBuildSnapshotFromRef_NoIdentifierTracked(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "dashboard.yaml", dashboardNoIdentifierYAML) + commitAll(t, repo.Dir, "add asset") + + snap, err := BuildSnapshotFromRef(context.Background(), repo, "HEAD", "") + require.NoError(t, err) + + require.Contains(t, snap.NoIdentifier, "dashboard.yaml") + assert.Equal(t, NoIdentifierDoc{Kind: "dashboard", FilePath: "dashboard.yaml"}, snap.NoIdentifier["dashboard.yaml"]) + assert.Empty(t, snap.Identifiers) +} + +func TestBuildSnapshotFromRef_MultiDocument(t *testing.T) { + repo := testRepo(t) + multiDoc := dashboardYAML + "---\n" + checkRuleYAML + writeFile(t, repo.Dir, "combined.yaml", multiDoc) + commitAll(t, repo.Dir, "add combined") + + snap, err := BuildSnapshotFromRef(context.Background(), repo, "HEAD", "") + require.NoError(t, err) + + assert.Equal(t, "combined.yaml", snap.Identifiers[IdentifierKey{Kind: "dashboard", Identifier: "a1b2c3d4-5678-90ab-cdef-1234567890ab"}]) + assert.Equal(t, "combined.yaml#1", snap.Identifiers[IdentifierKey{Kind: "checkrule", Identifier: "b2c3d4e5-6789-01bc-def0-234567890abc"}]) +} + +func TestBuildSnapshotFromRef_PrometheusRuleAlerts(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "rules.yaml", prometheusRuleYAML) + commitAll(t, repo.Dir, "add rules") + + snap, err := BuildSnapshotFromRef(context.Background(), repo, "HEAD", "") + require.NoError(t, err) + + require.Contains(t, snap.PrometheusAlertsByIdentifier, "shared-id") + assert.Len(t, snap.PrometheusAlertsByIdentifier["shared-id"], 2) +} + +func TestBuildSnapshotFromDisk_MatchesGitSide(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "dashboard.yaml", dashboardYAML) + writeFile(t, repo.Dir, ".hidden/skip.yaml", dashboardYAML) + writeFile(t, repo.Dir, "notes.txt", "not yaml") + + snap, err := BuildSnapshotFromDisk(context.Background(), repo.Dir, repo.Dir) + require.NoError(t, err) + + assert.Contains(t, snap.Identifiers, IdentifierKey{Kind: "dashboard", Identifier: "a1b2c3d4-5678-90ab-cdef-1234567890ab"}) + assert.True(t, snap.Paths["dashboard.yaml"]) + assert.NotContains(t, snap.Paths, ".hidden/skip.yaml") + assert.Len(t, snap.Identifiers, 1) +} + +func TestBuildSnapshotFromDisk_SingleFileScope(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "dashboard.yaml", dashboardYAML) + writeFile(t, repo.Dir, "checkrule.yaml", checkRuleYAML) + + snap, err := BuildSnapshotFromDisk(context.Background(), repo.Dir+"/dashboard.yaml", repo.Dir) + require.NoError(t, err) + + assert.Len(t, snap.Identifiers, 1) + assert.Contains(t, snap.Identifiers, IdentifierKey{Kind: "dashboard", Identifier: "a1b2c3d4-5678-90ab-cdef-1234567890ab"}) + assert.Equal(t, "dashboard.yaml", snap.Identifiers[IdentifierKey{Kind: "dashboard", Identifier: "a1b2c3d4-5678-90ab-cdef-1234567890ab"}]) +} + +// TestBuildSnapshotFromDisk_SingleFileScopeIgnoresExtension is a regression +// test for a bug where a single-file -f target without a .yaml/.yml +// extension (e.g. -f config.json) was silently excluded from the disk side +// of a --since scan, even though apply's own single-file create/update path +// (readMultiDocumentYAML) has no extension check at all and would read the +// exact same file just fine. +func TestBuildSnapshotFromDisk_SingleFileScopeIgnoresExtension(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "config.json", dashboardYAML) + + snap, err := BuildSnapshotFromDisk(context.Background(), repo.Dir+"/config.json", repo.Dir) + require.NoError(t, err) + + assert.Len(t, snap.Identifiers, 1) + assert.Contains(t, snap.Identifiers, IdentifierKey{Kind: "dashboard", Identifier: "a1b2c3d4-5678-90ab-cdef-1234567890ab"}, "a single-file scope must be scanned regardless of its extension") +} + +func TestBuildSnapshotFromDisk_PathsAlignWithRepoRootWhenScopeIsSubdirectory(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "sub/dashboard.yaml", dashboardYAML) + + snap, err := BuildSnapshotFromDisk(context.Background(), repo.Dir+"/sub", repo.Dir) + require.NoError(t, err) + + assert.True(t, snap.Paths["sub/dashboard.yaml"], "disk-side paths must be repo-root-relative, matching the git ls-tree side, even when scope is a subdirectory") +} diff --git a/internal/git/testrepo_test.go b/internal/git/testrepo_test.go new file mode 100644 index 00000000..88df569d --- /dev/null +++ b/internal/git/testrepo_test.go @@ -0,0 +1,59 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// testRepo creates a real temporary git repository with an initial empty +// commit, so HEAD always resolves. It returns a Repo pointed at it. +// +// This is an interim measure: once the checked-in zipped git-scenario +// fixtures (see openspec/changes/add-diff-and-since-flag/tasks.md, section +// 2) exist, these tests should migrate to testutil.UnzipGitScenario so every +// test tier shares one canonical repo state per scenario instead of building +// ad hoc repos inline. +func testRepo(t *testing.T) Repo { + t.Helper() + dir := t.TempDir() + runGit(t, dir, "init", "-q", "-b", "main") + runGit(t, dir, "config", "user.email", "test@example.com") + runGit(t, dir, "config", "user.name", "Test") + // This is the test's own throwaway repo (t.TempDir()), not the user's + // real repo or global git config: disable commit signing locally so + // tests don't depend on the machine's signing setup (e.g. a + // passphrase-protected SSH key with commit.gpgsign=true globally). + runGit(t, dir, "config", "commit.gpgsign", "false") + runGit(t, dir, "commit", "-q", "--allow-empty", "-m", "initial commit") + return Repo{Dir: dir} +} + +func runGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %v failed: %s", args, out) + return strings.TrimSpace(string(out)) +} + +// writeFile writes content to a repo-relative path inside dir, creating +// parent directories as needed. +func writeFile(t *testing.T, dir, relPath, content string) { + t.Helper() + full := filepath.Join(dir, relPath) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(content), 0o644)) +} + +// commitAll stages every change in dir and commits it. +func commitAll(t *testing.T, dir, message string) string { + t.Helper() + runGit(t, dir, "add", "-A") + runGit(t, dir, "commit", "-q", "-m", message) + return runGit(t, dir, "rev-parse", "HEAD") +} diff --git a/openspec/changes/add-asset-synch-action/design.md b/openspec/changes/add-asset-synch-action/design.md new file mode 100644 index 00000000..01fcdd3a --- /dev/null +++ b/openspec/changes/add-asset-synch-action/design.md @@ -0,0 +1,45 @@ +## Context + +`add-diff-and-since-flag` puts all deletion-detection logic inside the `dash0` binary and deliberately rejected a companion GitHub Action for that logic (see "Git lives inside the `dash0` binary, not a separate layer" in that change's `design.md`). This change is not that: it doesn't touch git-diffing or deletion detection at all. It exists purely because correctly gating `--since` around GitHub's event-payload quirks (the all-zeros sentinel, `before` being unset on non-push triggers, quoting) is boilerplate every adopting workflow would otherwise have to reproduce by hand, and this project already has precedent (`setup`, `send-log-event`) for eliminating that kind of repeated CI wiring with a small composite action. + +## Decisions + +### A thin wrapper, not a second implementation of ref resolution + +The action's only logic is: given the triggering event name and `github.event.before`, decide whether `--since ` is safe to pass, and either pass it (quoted) or omit it. It does not implement git-diffing or deletion-detection logic — `dash0 apply --since` already does all of that, including its own specific errors for the all-zeros/empty cases. The action does run a couple of lightweight git preflight checks of its own (resolvability, ancestry — see below) to decide whether to pass `--since` at all, but that's deciding *whether to pass the flag*, not duplicating what `dash0` does once it has one. + +### Gate on the event payload, not on `dash0`'s error output + +Considered alternative: always pass `--since ${{ github.event.before }}` and let `dash0`'s new specific error messages guide the user. Rejected as the action's default behavior: it would mean every first push to a new branch, and every `workflow_dispatch`/`schedule`/`pull_request` run, fails the whole job by default — defeating the point of a "wraps it correctly" action. The entire reason to build this is to make those cases *not* fail, by omitting `--since` automatically, rather than surfacing a well-worded error on every such run. + +### Fetch depth is still the caller's responsibility, but a too-shallow checkout gets its own specific error + +This action cannot fix an already-too-shallow checkout — by the time it runs, `actions/checkout` has already completed. It documents the concrete requirement (`fetch-depth: 0`, i.e. full history — not just "increase it," since `github.event.before` needs to cover however many commits the triggering push contained, which is unbounded and unknown in advance, unlike a fixed relative ref such as `HEAD~1`) rather than attempting to deepen the checkout itself, which would add a second git network operation with its own failure modes for uncertain benefit. + +What the action *does* do is validate, before invoking `dash0`, that a real-looking `before` value actually resolves within the checkout it's given (e.g. `git rev-parse --verify ^{commit}`). This three-way split matters: + +- `before` is all-zeros, empty, or unset → omit `--since`, proceed with a plain `apply`. Nothing is wrong; there's genuinely no prior state to diff against. +- `before` is real-looking and resolves → pass `--since `, quoted. +- `before` is real-looking but does *not* resolve (most commonly: `fetch-depth` too shallow to contain it) → fail the job with a specific error naming `fetch-depth` as the likely cause and `fetch-depth: 0` as the fix. + +The third case must fail loudly, not fall into the first case's "omit `--since`" behavior: unlike the sentinel/empty/unset cases, a too-shallow checkout means a real prior state *does* exist to diff against — the CI configuration just failed to fetch it. Silently omitting `--since` here would silently skip deletion detection because of a misconfigured checkout, which is exactly the class of quiet failure this whole feature (`add-diff-and-since-flag`) exists to close. `dash0 apply --since` itself would also produce an error for this case (a generic "unresolvable ref"), but performing the check in the action first lets the error carry CI-specific context — "this is your checkout's `fetch-depth`" — that the CLI has no way to know about. + +### A non-ancestor `before` fails the job too, for the same reason a too-shallow one does + +Since this action always passes `--force` to its `apply` invocation (see the `--force` decision in `add-diff-and-since-flag/design.md`), the CLI's own confirmation-bypass would silently let a non-ancestor `before` through — recomputing a deletion plan between two unrelated trees, exactly the mass-deletion risk `add-diff-and-since-flag/design.md`'s ancestor-check decision exists to guard against. The action therefore checks ancestry itself, alongside the existing resolvability check (`git rev-parse --verify` plus `git merge-base --is-ancestor HEAD`), before ever invoking `dash0`. A `before` that resolves but isn't an ancestor — the signature of a force-push or history rewrite on the tracked branch — fails the job with a specific error naming the likely cause, the same posture as the too-shallow-checkout case: a real anomaly in the CI's git state, not something to silently bypass just because `--force` happens to be set for other reasons. + +### Why a separate Action, not a CLI-side `--since auto` mode + +Considered alternative: fold this action's event-gating logic into `dash0` itself, e.g. a `--since auto` flag that reads `github.event.before` directly. Rejected: it would require the CLI to understand every CI platform's specific event-payload conventions (GitHub's `github.event.before`, and whatever the equivalent is on GitLab CI, CircleCI, Buildkite, and so on), directly at odds with keeping the CLI's own error text and behavior CI-agnostic (see `add-diff-and-since-flag/design.md`'s note on why the CLI's ref-resolution errors describe the git-level condition, not any specific CI provider's field name). + +A per-platform convenience package is the correct boundary instead: `asset-synch` is this project's GitHub Actions package; an equivalent package for another CI platform (a GitLab CI component, a CircleCI orb) would translate that platform's own event conventions the same way, without requiring `dash0` itself to know about any of them. This is the opposite arrangement from the git-diffing-in-an-Action alternative the sibling change rejected: that alternative would have put universally-needed logic (deletion detection, needed regardless of CI platform) in one platform-specific place. This action puts platform-specific logic (GitHub's event-payload translation) in a platform-specific place — the correct division of labor between a platform-agnostic core tool and platform-specific glue, not a repeat of the same mistake one layer up. + +### The action calls `apply`, not `diff`, but exposes the resolved ref either way + +The action's primary job is running `dash0 apply -f ` with the correctly-resolved `--since` argument, since that's the actual sync operation a GitOps pipeline needs. But the resolution logic (event name + `before` → safe `--since` value or omission) is exactly what a workflow calling `dash0 diff` instead would also need, so that resolved value is exposed as an output rather than being locked inside the action's own `apply` invocation. + +## Non-goals + +- Auto-deepening a shallow `actions/checkout` from within this action. +- Any git-diffing or deletion-detection logic (stays inside `dash0`, per `add-diff-and-since-flag`). +- A GitLab CI or other-CI equivalent — GitHub Actions only, matching the existing two composite actions. diff --git a/openspec/changes/add-asset-synch-action/proposal.md b/openspec/changes/add-asset-synch-action/proposal.md new file mode 100644 index 00000000..efe668be --- /dev/null +++ b/openspec/changes/add-asset-synch-action/proposal.md @@ -0,0 +1,21 @@ +## Why + +`dash0 apply --since ` and `dash0 diff --since ` (added by `add-diff-and-since-flag`) need `` to be a git ref that resolves cleanly against the checked-out history — but wiring that correctly from a GitHub Actions workflow has real, well-documented papercuts (see `add-diff-and-since-flag/design.md`): `github.event.before` is git's all-zeros SHA sentinel on a branch's first push, is unset entirely on trigger types like `workflow_dispatch`, `schedule`, or `pull_request`, and an unquoted or ungated interpolation can produce a bare `--since` with nothing following it, or one that silently swallows a neighboring flag as its value. `dash0` itself gives the first two cases (all-zeros sentinel, empty string) a specific, actionable error, but a workflow author still has to hand-write the `if:` gate and quoting correctly on every workflow that wants deletion-aware sync — exactly the kind of repeated, error-prone boilerplate this project's existing composite actions (`.github/actions/setup/`, `.github/actions/send-log-event/`) already exist to eliminate for CLI setup and log sending. + +## What Changes + +- Add a new composite GitHub Action, `.github/actions/asset-synch/`, that wraps `dash0 apply -f ` and handles `--since` resolution on the caller's behalf: + - Reads the triggering event name and `github.event.before` itself. + - Omits `--since` entirely when `before` is unset, empty, or git's all-zeros SHA sentinel — the exact cases a hand-written workflow has to gate against manually today — and the job does not fail in any of those cases. + - When `before` is a real-looking value, validates that it actually resolves within the current checkout before invoking `dash0`. If it doesn't — most commonly because the checkout's `fetch-depth` is too shallow to contain it — the action fails the job with a specific, actionable error naming `fetch-depth` as the likely cause, rather than either silently omitting `--since` (which would silently skip deletion detection due to a misconfigured checkout) or letting the CLI's own generic unresolvable-ref error surface with no CI-specific context. + - Passes `--since ` correctly quoted once it's confirmed to resolve, and always passes `--force` (added to `apply` by `add-diff-and-since-flag`) since this action is designed exclusively for unattended CI, where the confirmation prompt has no one to answer it. Since `--since` is gated behind `--experimental`/`-X` for now (see `add-diff-and-since-flag/design.md`), the action also passes `-X` whenever it passes `--since` — but not on the omitted-`--since` branches, which need no gate. This is a transitional requirement: once `--since` is promoted to stable, `-X` drops out of the action's invocation with no other change needed. +- Does not reimplement or duplicate any git-diffing or deletion-detection logic — that remains entirely inside the `dash0` binary per `add-diff-and-since-flag`. This action is CI ergonomics only: computing a safe argument list and invoking the CLI, the same division of labor `send-log-event` already has with `dash0 logs send`. +- Exposes the resolved `since` value (possibly empty) as an action output, so a workflow that wants to call `dash0 diff` itself — instead of, or in addition to, having this action call `apply` — can reuse the same resolution rather than re-deriving it. + +## Impact + +- Affected: new `.github/actions/asset-synch/action.yaml` and `.github/actions/asset-synch/README.md`, `docs/github-actions.md`, `docs/github-actions-maintenance.md`. +- New spec capability: `github-actions` (this change introduces it; `add-diff-and-since-flag` has no such capability today). +- Depends on `add-diff-and-since-flag`: this action is a thin wrapper around `apply --since` / `diff --since` and has no purpose without those flags existing. +- Prerequisite unchanged from `add-diff-and-since-flag`: the workflow's checkout step must still fetch enough git history for `` to resolve — this action runs after checkout and cannot retroactively deepen an already-shallow clone. Since `github.event.before` needs history covering however many commits the triggering push contained (unbounded, unlike a fixed relative ref), the concrete recommendation is `fetch-depth: 0` (full history) in the workflow's `actions/checkout` step. When that prerequisite isn't met, the action's own preflight check (above) fails the job with a message pointing at this exact fix, rather than a bare git error. +- Out of scope: auto-deepening a shallow checkout from within this action, and any git-diffing or deletion-detection logic (both stay out, per the "Git lives inside the `dash0` binary" decision in `add-diff-and-since-flag/design.md`). A GitLab CI or other-CI equivalent is also out of scope — GitHub Actions only, matching the existing two composite actions. diff --git a/openspec/changes/add-asset-synch-action/specs/github-actions/spec.md b/openspec/changes/add-asset-synch-action/specs/github-actions/spec.md new file mode 100644 index 00000000..91ab6887 --- /dev/null +++ b/openspec/changes/add-asset-synch-action/specs/github-actions/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: New `asset-synch` composite GitHub Action +A new composite action at `.github/actions/asset-synch/` SHALL wrap `dash0 apply -f `, resolving whether `--since` is safe to pass from the triggering event, rather than requiring the calling workflow to hand-write that gating logic. Since `--since` is gated behind `--experimental`/`-X` (a transitional requirement — see `add-diff-and-since-flag/design.md`), the action SHALL pass `-X` whenever it passes `--since`, and SHALL NOT pass `-X` on the branches where `--since` is omitted (no gate applies there). + +#### Scenario: Ordinary push with a valid `before` +- **GIVEN** a `push` event where `github.event.before` resolves to a real commit +- **WHEN** the `asset-synch` action runs +- **THEN** it invokes `dash0 apply -f --since --force --experimental`, `` correctly quoted — `--force` is always passed since this action is designed exclusively for unattended CI, and `--experimental` is passed because `--since` requires it for now + +#### Scenario: First push to a new branch +- **GIVEN** a `push` event where `github.event.before` is git's all-zeros SHA sentinel +- **WHEN** the `asset-synch` action runs +- **THEN** it invokes `dash0 apply -f --force` without `--since`, and the job does not fail + +#### Scenario: Non-push trigger with no `before` +- **GIVEN** a trigger event (e.g. `workflow_dispatch`, `schedule`, `pull_request`) that does not define `github.event.before` +- **WHEN** the `asset-synch` action runs +- **THEN** it invokes `dash0 apply -f --force` without `--since`, and the job does not fail + +### Requirement: A real but unresolvable `before` fails the job with a specific error +When `github.event.before` is a real-looking value (not all-zeros, not empty, not unset) but does not resolve within the current checkout, the `asset-synch` action SHALL fail the job before invoking `dash0`, with an error identifying `fetch-depth` as the likely cause and recommending `fetch-depth: 0` in the workflow's checkout step. It SHALL NOT omit `--since` and proceed with a plain apply in this case — unlike the all-zeros/empty/unset cases, a too-shallow checkout means a real prior state exists to diff against, and silently skipping `--since` here would silently skip deletion detection because of a misconfigured checkout. + +#### Scenario: Checkout too shallow to resolve `before` +- **GIVEN** a `push` event where `github.event.before` names a real commit, but the workflow's checkout step fetched too little history to contain it +- **WHEN** the `asset-synch` action runs +- **THEN** the job fails before `dash0` is invoked, with an error naming `fetch-depth` as the likely cause and recommending `fetch-depth: 0` + +### Requirement: A non-ancestor `before` fails the job with a specific error, even though `--force` is always passed +Since the action always passes `--force` to `apply` (which would otherwise silently proceed past a non-ancestor ref), the `asset-synch` action SHALL check ancestry itself before invoking `dash0`: when `before` resolves to a real commit that is not an ancestor of the current commit, the action SHALL fail the job before invoking `dash0`, with an error identifying the likely cause (a force-push or history rewrite on the tracked branch) — the same posture as the too-shallow-checkout case. + +#### Scenario: Force-pushed branch produces a non-ancestor `before` +- **GIVEN** a `push` event where `github.event.before` resolves to a real commit that is not an ancestor of the current commit (e.g. after a force-push) +- **WHEN** the `asset-synch` action runs +- **THEN** the job fails before `dash0` is invoked, with an error naming the likely cause (force-push or history rewrite) + +### Requirement: Resolved `since` value is exposed as an output +The action SHALL expose the value it decided to use for `--since` (or an empty value when omitted) as an action output, so a workflow that wants to call `dash0 diff` directly — instead of, or in addition to, this action's own `apply` call — can reuse the same resolution. + +#### Scenario: Consuming the output for a `diff` step +- **GIVEN** a workflow with an `asset-synch` step followed by a separate step calling `dash0 diff` +- **WHEN** the workflow references the `asset-synch` step's `since` output +- **THEN** the value is the same one `asset-synch` used (or empty, in the omitted cases), so the `diff` step and the `asset-synch` step agree on whether `--since` applies + +### Requirement: No git-diffing logic is duplicated in the action +The action SHALL NOT implement its own git history comparison, deletion detection, or asset-identifier parsing — all of that SHALL remain inside `dash0 apply --since` / `dash0 diff --since`. The action's only responsibility is deciding whether `--since` is safe to pass and invoking the CLI accordingly. + +#### Scenario: Action delegates deletion detection to the CLI +- **GIVEN** a `push` event with a valid `before` +- **WHEN** the `asset-synch` action invokes `dash0 apply -f --since --force --experimental` +- **THEN** all deletion detection, kind dispatch, and confirmation-bypass behavior is exactly what `dash0 apply --since --force --experimental` already does on its own — the action adds no additional logic beyond flag construction diff --git a/openspec/changes/add-asset-synch-action/tasks.md b/openspec/changes/add-asset-synch-action/tasks.md new file mode 100644 index 00000000..2da48506 --- /dev/null +++ b/openspec/changes/add-asset-synch-action/tasks.md @@ -0,0 +1,32 @@ +**Depends on `openspec/changes/add-diff-and-since-flag`** — this action wraps `apply --since`/`--force`, which must exist first (Sections 1, 3, and 4 there — including the experimental gate, since the action's own invocation must pass `-X` while it's in effect). The action's YAML/docs/test-workflow scaffolding (below) can be drafted in parallel, but its preflight logic can't be verified end-to-end until the flag work lands. + +## 1. Composite action + +- [ ] 1.1 New `.github/actions/asset-synch/action.yaml`, following `.github/actions/setup/action.yaml` and `.github/actions/send-log-event/action.yaml`'s conventions (composite action, versioned, referenced by SHA from consumers). +- [ ] 1.2 Inputs: at minimum the directory to sync (mirroring `apply -f `); reuse whatever the `setup` action already establishes for profile/auth rather than re-accepting `api-url`/`auth-token` directly, if `setup` is expected to run first in the same job. +- [ ] 1.3 Read the triggering event name (`github.event_name`) and `github.event.before` inside the action's own script step. +- [ ] 1.4 Classify `before`: unset, empty, or git's all-zeros SHA sentinel → omit `--since` entirely, proceed with a plain `dash0 apply -f --force` (no `-X` needed here — no `--since` means no gate to satisfy), job does not fail. +- [ ] 1.5 Otherwise, run the resolvability preflight (`git rev-parse --verify "$BEFORE^{commit}"` or equivalent) — if it fails, fail the job with an error naming `fetch-depth` as the likely cause and `fetch-depth: 0` as the fix (do not fall back to a plain apply; a real prior state exists to diff against, silently proceeding would silently skip deletion detection). +- [ ] 1.6 Then run the ancestry preflight (`git merge-base --is-ancestor "$BEFORE" HEAD`) — if it fails (resolves but isn't an ancestor), fail the job with an error naming the likely cause (force-push or history rewrite on the tracked branch). This check is independent of `--force` — the action always passes `--force` to `apply`, so this preflight is the only thing standing between a force-pushed branch and a silently-computed deletion plan between two unrelated trees. +- [ ] 1.7 Otherwise, invoke `dash0 apply -f --since "$BEFORE" --force --experimental`, `$BEFORE` correctly quoted. `--experimental`/`-X` is required for now because `--since` is gated behind it (see `add-diff-and-since-flag/design.md`) — passed only on this branch, never on the omitted-`--since` branches above. This is transitional: drop it once `--since` is promoted to stable, with no other change to the action needed. +- [ ] 1.8 Expose the resolved `since` value (or empty, in the omitted cases) as an action output, so a workflow that wants to call `dash0 diff` itself can reuse the same resolution. +- [ ] 1.9 No git-diffing or deletion-detection logic of its own beyond the two preflight checks above — everything else stays inside `dash0`. + +## 2. Documentation + +- [ ] 2.1 `.github/actions/asset-synch/README.md`, following `.github/actions/setup/README.md`/`.github/actions/send-log-event/README.md`'s structure — inputs, outputs, a minimal usage example, and the `fetch-depth: 0` requirement called out explicitly (link to `add-diff-and-since-flag`'s fetch-depth guidance). +- [ ] 2.2 `docs/github-actions.md`: add an `asset-synch` section alongside the existing `setup`/`send-log-event` sections. +- [ ] 2.3 `docs/github-actions-maintenance.md`: add `asset-synch` to the "keeping the actions in sync with CLI changes" guidance — specifically, any future change to `apply --since`'s ref-resolution error semantics (all-zeros/empty/non-ancestor/too-shallow) needs a corresponding check in this action's preflight logic. +- [ ] 2.4 Changelog entry (`make chlog-new`) for the new `asset-synch` action. + +## 3. Testing + +- [ ] 3.1 New workflow `.github/workflows/test-asset-synch-action.yml`, following `.github/workflows/test-setup-action.yml`'s pattern (runs on every PR and push to `main`, plus `workflow_dispatch`) — since this action's correctness depends on `dash0 apply`'s ref-resolution behavior, the same rationale for testing on every push applies. +- [ ] 3.2 Test scenarios, each as a separate job: ordinary push with a valid `before` (since gets passed, quoted, `--force` and `--experimental` present); first push to a new branch (all-zeros sentinel, `--since`/`--experimental` omitted, job succeeds); `workflow_dispatch` (no `before`, `--since`/`--experimental` omitted, job succeeds); too-shallow checkout (job fails with the `fetch-depth` message); non-ancestor `before` via a simulated force-push (job fails with the force-push/history-rewrite message). +- [ ] 3.3 Verify the `since` output is set correctly (or empty) in each scenario, and that a downstream step can consume it. + +## 4. Verification + +- [ ] 4.1 All scenarios in `test-asset-synch-action.yml` pass. +- [ ] 4.2 `make lint` passes (`shellcheck` on the action's script steps, if inline bash is used). +- [ ] 4.3 Manual smoke test: adopt `asset-synch` in a real (or scratch) workflow and confirm it behaves correctly on an ordinary push, a first push to a new branch, and a `workflow_dispatch` run. diff --git a/openspec/changes/add-diff-and-since-flag/design.md b/openspec/changes/add-diff-and-since-flag/design.md new file mode 100644 index 00000000..04f301cc --- /dev/null +++ b/openspec/changes/add-diff-and-since-flag/design.md @@ -0,0 +1,107 @@ +## Context + +Primary use case: unattended GitOps CI (a workflow running `dash0 apply -f ` on every push), not interactive local iteration. That framing drives every decision below toward "safe to run unattended" and "fails loudly rather than partially" over interactive polish. + +## Decisions + +### `diff` and `apply --since` are gated behind `--experimental`, requiring a new flag-level gate mechanic + +Both are genuinely new, higher-risk behavior — `diff` is a brand-new command, and `--since` adds real deletion to a command (`apply`) that has only ever created and updated. Following this project's own convention (`docs/adding-commands.md`), anything that isn't a slam-dunk stable design ships behind `-X` first and gets promoted once real usage validates it (`docs/promoting-commands-to-stable.md`). + +`diff` gates the ordinary way: `experimental.RequireExperimental(cmd)` at the top of `RunE`, the `[experimental]` prefix on `Short`, `-X` in every `Example` line — identical to every other experimental command (`otlp proxy`, `teams`, `spam-filters`, etc.). + +`apply --since` cannot use that same helper as-is. `internal/experimental.RequireExperimental`'s own doc comment says so explicitly: "this function assumes that what is experimental is a subcommand, rather than a flag on an otherwise non-experimental command." `apply` itself is stable and heavily used (it's the command this whole feature's motivating GitOps workflows already depend on for create/update) — gating the whole command behind `-X` to protect just the new `--since` behavior would force every existing `apply` caller to add `-X` for no reason, or would require a confusing carve-out. Considered alternative: promote `apply` itself to experimental only when `--since` is used, by checking the flag inline in `runApply` without adding shared infrastructure. Rejected: this is exactly the kind of gate every other experimental command already needs, just at flag granularity instead of command granularity — worth a small, reusable addition to `internal/experimental` (`RequireExperimentalFlag(cmd, flagName string) error`, gating on `cmd.Flags().Changed(flagName)` so the check is a no-op whenever the flag wasn't passed) rather than a one-off inline check that the next feature needing the same shape would have to reinvent. + +`--force` needs no gate of its own: it has no effect unless combined with `--since` (there is no other confirmation prompt anywhere else in `apply` today), so it's covered transitively by `--since`'s gate. `apply` without `--since` is completely unaffected: no gate, no behavior change, today's stable command exactly as it is. + +### Git lives inside the `dash0` binary, not a separate layer + +`apply --since` and `diff --since` shell out to `git` directly, in-process. Considered alternative: keep `dash0` fully git-agnostic (like `kubectl`) and push the git diffing into an external script or GitHub Action that calls granular `dash0 delete` commands. Rejected: it reintroduces exactly the split-across-two-layers complexity (a separate tool to build, test, and document) that a single CLI flag avoids. The cost is that `dash0` gains a runtime dependency on `git` for this one feature — acceptable because every context this targets (CI, a git checkout) already has `git` available. + +This is a deliberate departure from the "kubectl for Dash0" positioning, worth naming explicitly rather than leaving implicit: `kubectl` itself never reads a VCS to decide what to prune — that reconciliation role belongs to a separate layer (ArgoCD, Flux) in the Kubernetes ecosystem. Dash0 has no equivalent separate reconciler for its own assets, so `--since` puts that role inside `dash0` itself rather than leaving it unbuilt. The tradeoff is the `git` runtime dependency taken on above, in exchange for `dash0` being the only interface an asset owner needs for GitOps-style sync, with no second tool to adopt. + +Within that, only plumbing commands are used — `git rev-parse` (ref resolution and validation), `git diff-tree` (enumerating added/modified/deleted paths between two trees), `git cat-file` (reading a file's content at a given ref), and `git ls-tree` (enumerating a tree's entries) — never porcelain commands like `git diff`, `git show`, or `git log`. Porcelain output is designed for human consumption on a terminal and its format is not a stable, documented contract across git versions; plumbing commands exist specifically for scripting and have stable, documented output shapes. Since `--since` parses git's output to drive real deletions, it needs the latter. + +Considered alternative: use a native Go git library (e.g. `github.com/go-git/go-git`) instead of shelling out to the system `git` binary, reading git objects in-memory with no subprocess. This would remove the `git`-on-`PATH` runtime dependency entirely — including erasing the `FROM scratch` Docker image's lack of `git` as a limitation, rather than merely documenting it. Rejected for this feature specifically: `go-git` is a from-scratch reimplementation of git in Go, not a wrapper around the real thing, and two of the exact mechanisms this design leans on hardest are where a reimplementation is riskiest. First, `--since ` deliberately accepts "any revision expression git itself accepts," but `go-git`'s revision resolution supports a subset of git's revparse grammar, not the full thing — adopting it would quietly narrow that promise. Second, the too-shallow-ref error path is central to this whole design (it's why the `fetch-depth: 0` guidance exists at all); the shallow clone in CI is produced by the real git binary (`actions/checkout`), and a reimplementation's handling of `.git/shallow`/grafted history has to match that exactly for "does this ref actually resolve" to be trustworthy. A subtle divergence there would not fail loudly — it would silently misjudge whether an asset should be deleted, which is precisely the failure class this feature exists to close. Given this design's own bias toward failing loudly over silent partial correctness (see the ancestor-check decision below), matching the exact tool that produced the checkout is worth more here than removing the runtime dependency; the Docker-image limitation stays a documented, narrow edge case rather than becoming a silent-correctness risk everywhere else. + +### Detection compares git history, not live server state + +Considered alternative: mirror `kubectl apply --prune`, which doesn't use git at all — it lists everything on the server carrying a given label and deletes whatever isn't in the current apply set. The Dash0 equivalent would list assets by `dash0.com/origin` and diff against the current directory's ids. Rejected for this change: it is problematic in scenarios when not all assets in a Dash0 org or dataset are managed from the same git repository or folder therein. + +This narrows but does not eliminate that same failure mode: an asset whose file moves to a *different* repository (not just a different subdirectory within the same scanned directory) looks identical to a deletion from the old repository's perspective — identity-by-path protection (see "Identity is the asset's id/origin" below) only covers renames and moves within the scope a single `--since` run scans. Accepted limitation, not addressed by this change: a repository that stops managing an asset (moved to another repo, another team) must not point `--since` at a ref spanning that removal for that asset, or the asset needs to be re-established under the new repository's management before the old repository's next `--since` run. + +### `--since ` is explicit-only; a bad ref fails the whole command + +No default ref-guessing, and no automatic fallback for an unresolvable or too-shallow ref: it is treated as a plain git error, and the entire `apply` call fails before creating, updating, or deleting anything — it does not fall back to create/update-only. This keeps the contract to one flag with one failure mode; the calling workflow is responsible for deciding whether to pass `--since` at all. + +`` is not restricted to branch names, tags, or full SHAs — any revision expression git itself accepts works, including relative expressions like `HEAD~`. This is not a hypothetical: it is exactly what dash0-configuration's own `dash0-cli-apply.yaml` workflow already does today (`git diff --name-only HEAD~1 HEAD` to decide whether to run `apply` for a given asset directory) — `--since HEAD~1` is a directly supported, first-class input, not a gap or a special case requiring extra design. `github.event.before` is one convenient ref source among several a caller might reach for; `HEAD~` is another with its own tradeoff (fixed distance regardless of how many commits a single push contains, versus `before`'s exact-but-sentinel-prone precision) — that tradeoff is the calling workflow's to make, not something `--since` needs to adjudicate. + +Two specific, recognizable values get a message-only exception — same failure mode (whole command fails, no fallback), but a specific, actionable error instead of the generic git-resolution error, since both are common real-world results of imperfect GitHub Actions wiring rather than a typo'd ref: + +- Git's all-zeros SHA sentinel (`0000000000000000000000000000000000000000` — the value GitHub gives `github.event.before` on a branch's first push, since the ref didn't previously exist). +- An empty string (`--since ""` — the value a properly-quoted `--since "${{ github.event.before }}"` interpolates to on trigger types that don't define `before` at all, e.g. `workflow_dispatch`, `schedule`, `pull_request`). + +Both messages name the specific condition and point at the fix: skip `--since` for that invocation (e.g. a workflow condition on `github.event.before` or the event name), or pass an explicit ref if one exists. Any other unresolvable ref (a typo, an unrelated branch name, a too-shallow clone) keeps the plain git error, since those are more likely to be genuine mistakes worth surfacing as-is rather than papering over with a guessed diagnosis. + +Two adjacent failure modes are deliberately *not* addressed by `dash0`'s own logic, because they never reach it as a distinguishable value: + +- `--since` with nothing following it at all — an unquoted `--since ${{ github.event.before }}` with an empty expression and nothing else on the line. This is a plain pflag/cobra argument-parsing error (`flag needs an argument: --since`) raised before `--since`'s own resolution logic ever runs. +- The empty expansion silently swallowing an unrelated neighboring token as the ref value — e.g. `--since ${{ github.event.before }} --force` with an empty expression, where `--since` consumes `--force` as its argument. `dash0` receives an ordinary-looking bad-ref string with no way to tell it apart from a genuine typo. + +Both are pure shell/YAML authoring mistakes, not conditions `dash0` can detect or diagnose from inside the process. They are addressed by documentation instead: showing the correct, safe invocation pattern (quote the interpolated value, gate it with an `if:` condition on the trigger) so a workflow author copies a form that can't produce either failure, rather than discovering them by trial and error. + +### A resolvable-but-non-ancestor ref requires confirmation, not a hard failure + +`` resolving to a real commit object doesn't guarantee it's an ancestor of the current HEAD — a force-push or history rewrite on the tracked branch can leave `github.event.before` (or any other `--since` source) pointing at a commit that still exists but is no longer reachable from HEAD. Comparing trees between two unrelated commits can produce a large, spurious deletion set (everything in the old tree that isn't in the new one), which is exactly the mass-deletion risk this feature exists to avoid, not enable. + +Considered alternative: hard-fail unconditionally on a non-ancestor ref, the same as any other unresolvable-ref case. Rejected: a legitimate force-push (rewriting history to fix a mistake, a routine-enough operation) would then have no recovery path for `--since` at all — the calling workflow could never proceed past that push, permanently, since there's no way to say "yes, I know, proceed anyway." Instead, a non-ancestor ref goes through the same confirmation mechanism as any other destructive operation in this CLI: prompt for confirmation interactively (naming the likely cause so the operator can abort if it's a mistake), auto-proceed when `--force` or agent-mode is active, and fail when neither applies and no terminal is available to prompt on. + +### Identity is the asset's id/origin, never its local file path + +The target directory (`-f `) is a local discovery root only — "recursively find every `.yaml`/`.yml` file here" — with no relationship to Dash0's own folder placement, which is controlled independently by each document's `dash0.com/folder-path` annotation. Consequently, an asset whose file was renamed or moved to a different subdirectory (but is still present somewhere under the scanned directory) must never be deleted, even though git reports its old path as removed. Matching is by the asset's id/origin label read from the file content, not by path. + +### Detection compares identifier sets, not whole files + +A single file can yield more than one asset when it's a multi-document YAML file (documents separated by `---`) — one identifier per document. `PrometheusRule` CRDs are different: identity is CRD-level, not per-rule, matching the model `check-rules create`/`apply` already use today — every alerting rule converted from one CRD shares that CRD's single `dash0.com/id` label (`docs/commands.md`: "a CRD with multiple alerts shares one identifier"). Consequently, `--since` detection operates on the set of asset identifiers (id/origin, per kind) obtained by parsing every document across the scanned scope — one per YAML document, one per `PrometheusRule` CRD — at `` and at the current commit, not on whole-file presence. An identifier present at `` and absent now is a deletion candidate, whether that is because its enclosing file was deleted outright or its document was removed from a multi-document file that still exists. A consequence for multi-document YAML: `-f ` is not automatically a deletion no-op the way "the file still exists" might suggest — a surviving multi-document file can still lose one of its constituent assets. For `PrometheusRule`, this doesn't apply the same way at the identifier level: since identity is CRD-level, the CRD's shared identifier persists regardless of which individual rules come and go, the same as it already does for create/update today. + +Removing one alerting rule from a CRD that still has others is a narrower case the identifier model above doesn't cover on its own, but it is still detectable: comparing the parsed rule list (`group.name` + `alert.name`) between `` and the current commit surfaces exactly which alert disappeared, even though the CRD's own identifier persists. Detecting it this way is straightforward; deleting it is not, because every alert in the CRD shares that one identifier — there is no per-alert id to pass to `check-rules delete`. The removed alert's actual server-side check rule must instead be resolved by name (` - `, the same naming convention `check-rules create` already uses for CRD-derived check rules) via a list-and-match call, then deleted by whatever id that lookup resolves to. This is the one case in this feature where deletion needs a name-based lookup rather than a direct identifier match, worth calling out explicitly since it doesn't fit the identifier-only model used everywhere else. + +Recording rules don't have this problem: `record:` entries within one CRD are never individually named or created as separate resources — the whole recording-rule CRD is submitted as one PUT/POST to the recording-rules endpoint. Removing one `record:` entry from a CRD that still has others is a plain update (the reapplied CRD simply has fewer records), not a deletion — the existing create/update path already handles it correctly with no new logic needed. + +This is a two-point comparison — ``'s identifier set (read from git) against the current identifier set — not a scan of every commit in between. The "current" side is the literal disk contents of the scanned directory, the same files `apply`'s existing create/update path already reads (`internal/apply/apply.go`'s directory discovery) — not a second git-object read of HEAD. This matters in interactive use: an uncommitted local deletion (a file removed from disk but not yet committed) is still visible to `--since`, the same way it's already visible to plain `apply`'s create/update path today. A direct consequence: an asset added and then removed again somewhere in the commits between `` and HEAD is absent at both comparison points, so it is invisible to `--since` — not reported as a creation, an update, or a deletion. This is correct, not a gap: nothing needs to happen for an asset that doesn't exist at either end of the comparison. It also constrains the implementation: computing the identifier sets at the two endpoints directly (e.g. via `git diff-tree` between the two trees) gets this behavior for free, whereas accumulating changes commit-by-commit across the range would not. + +### Deletion does not verify the asset's current state matches git history + +`--since` deletes an asset purely because its identifier is present at `` and absent now — it does not compare the asset's current live state in Dash0 against the content git last recorded for that identifier before deleting. If the asset was modified out-of-band (e.g. via the Dash0 UI) after the last commit that touched it, `--since` still deletes it: there is no drift check, no "does the server still match what git last saw" guard. This mirrors every other destructive command in this CLI — ` delete` has no such check either — and keeps the deletion path a plain identifier lookup; the confirmation prompt (or `--force`/agent-mode bypass) remains the only safeguard, the same as elsewhere. + +### Deletion reuses the existing confirmation mechanism; no new prompt logic + +`--since`-triggered deletions go through `confirmation.ConfirmDestructiveOperation`, the same helper every ` delete` command already uses — prompting per asset unless `--force` or agent-mode is active. No bespoke "sync mode" confirmation behavior is introduced. This requires adding a `--force` flag to `apply` itself, which has none today: `apply`'s primary use case (unattended GitOps CI) means the confirmation prompt is structurally unreachable there, so `asset-synch` passes `--force` on its `apply` invocation — the flag exists on `apply` specifically so unattended callers have a way to opt out of a prompt they can never answer. + +One deliberate divergence from every existing ` delete` command: declining that command's single confirmation exits `0` — the user changed their mind about one explicit delete, nothing more to say. Declining a deletion inside a `--since` run is different: the *sync's* desired end-state ("this asset is gone, matching what git shows") wasn't reached, even though the rest of the run succeeded. Exiting `0` there would let a CI pipeline report success while quietly leaving an asset it was told to remove still present — exactly the class of silent, easy-to-miss failure this whole feature exists to close. So a `--since` run exits non-zero when any deletion was declined, even though the analogous standalone ` delete` invocation would not. + +### A deleted file with no identifier fails the whole `--since` run + +Some assets (e.g. a `Dashboard` created without `metadata.dash0Extensions.id`) have no stable identifier — every prior `apply` run for that file would have created a new server-side asset each time. When such a file is deleted, there is no way to know which server asset (if any) it corresponds to. Considered alternative: skip that single file's deletion with a warning and let the rest of the `--since` run proceed. Rejected: a warning that gets scrolled past in CI output is exactly the silent, easy-to-miss failure this feature exists to close — an orphaned asset would persist with no hard signal that anything is wrong. Instead, `--since` requires every deleted file's content at `` to carry a stable identifier; if any deleted file lacks one, the entire run fails before creating, updating, or deleting anything, the same failure mode as an unresolvable ref. This pushes the fix to where it belongs: the asset's source file needs an id/origin before `--since` can be relied on for it. + +This mirrors precedent already in production: `dash0-configuration`'s `validate-synthetic-checks.sh` exists specifically because a missing `dash0.com/id` made `apply` create a new synthetic check on every run instead of updating in place — 498 duplicate checks accumulated before that gap was closed (PLA-1424). The fix that incident produced was a hard CI failure on a missing id, not a warning that let the run continue. Treating a missing identifier any more leniently on the delete side than this team already treats it on the create side would be an inconsistent posture for the same underlying invariant — an asset's identifier is load-bearing, and `apply` cannot safely act on one it can't establish. + +### `diff` uses a three-way exit code, a deliberate exception to this CLI's uniform convention + +This was raised and settled once already (keep uniform exit 1, carry the distinction in output content) and reopened on a second review pass with a sharper argument: `diff` is explicitly modeled on `kubectl diff` (see the "Why" section, citing issue #232), and `kubectl diff` exists specifically so CI scripts can branch on "changes pending" versus "something broke" via exit code alone, without parsing output. Collapsing both outcomes into exit `1` — even with the distinction visible in output content — defeats that exact automation use case for any caller that only checks the exit code, which is the normal way scripts consume a diff-style command. + +The exception is scoped to `diff` alone: `0` no differences, `1` at least one difference (create/update/delete pending), `2` a genuine error. `apply` (and its deprecated `--dry-run` flag) keeps this CLI's uniform 0/1 exit-code convention unchanged — it mutates state, it isn't the command answering the `kubectl diff` half of issue #232, and diverging its exit codes too would spread the exception well beyond what motivates it. + +### `apply --dry-run` is deprecated in favor of `diff`, not held to permanent parity + +Round 2 review surfaced that requiring `apply --dry-run --since` to "behave identically" to `diff --since` forever — matching exit codes, confirmation-bypass rules, and output format — either commits to sharing one implementation (fine) or to two parallel ones kept in sync by discipline alone (a compounding maintenance and test cost, and a likely source of silent drift). Rather than carrying that permanent-parity contract, `apply --dry-run` is deprecated in favor of `dash0 diff`: it keeps working for backward compatibility (a deprecation warning printed to stderr, the same pattern already used elsewhere in this CLI, e.g. `teams update --name`), but its output and exit-code behavior are not guaranteed to track `diff`'s going forward. `diff` is the actively-maintained, canonical preview surface; `--dry-run` is legacy scaffolding kept working, not a twin kept in lockstep. + +## Non-goals + +- Supporting deletion detection for directories that aren't inside a git checkout, or environments with insufficient git history for the given ref. +- Pluggable external diff tools. +- A "sync" command distinct from `apply`/`diff`, or a bundled GitHub Action implementing git-diffing/deletion-detection logic (that logic lives entirely inside the `dash0` binary — see "Git lives inside the `dash0` binary" above). A separate, thin GitHub Action that only gets `--since`'s GitHub Actions wiring right (quoting, gating on the all-zeros/empty/undefined cases) — with no git-diffing logic of its own — is a distinct concern, tracked in `openspec/changes/add-asset-synch-action`. + +## Open Questions + +- **A safeguard beyond `--force` for unattended runs with a resolvable-but-wrong diff.** Once `--force` (bypassing the confirmation prompt) and the ancestor check (catching force-push/history-rewrite anomalies) both exist, nothing else protects an unattended `--since` run from a legitimate-but-over-broad ref producing a larger deletion set than intended — the confirmation prompt is the only safeguard, and it's structurally bypassed in exactly the unattended-CI scenario this feature targets. A possible mitigation: a maximum deletion count or percentage guard for non-interactive runs specifically, still requiring explicit override. Raised in review; deferred as new feature scope beyond what this change already plans, worth revisiting for a future iteration once real-world `--since` usage shows whether this risk materializes in practice. diff --git a/openspec/changes/add-diff-and-since-flag/proposal.md b/openspec/changes/add-diff-and-since-flag/proposal.md new file mode 100644 index 00000000..4ca15c8d --- /dev/null +++ b/openspec/changes/add-diff-and-since-flag/proposal.md @@ -0,0 +1,30 @@ +## Why + +`dash0 apply -f ` syncs a directory of Dash0 asset YAML files into Dash0 (dashboards, views, check rules, synthetic checks, recording rules, notification channels, spam filters, teams), but it has no way to detect that a file was removed. In the primary use case — a GitOps CI pipeline that runs `dash0 apply -f` on every push (as this project's own `dash0-configuration` repo does) — deleting an asset's file from the tracked directory leaves the asset orphaned on the Dash0 server forever. Nothing removes it. + +Separately, `dash0 apply --dry-run` only validates documents locally: it never queries Dash0, so it cannot distinguish a create from an update, or show what a real diff would look like. This is the exact gap raised in [issue #232](https://github.com/dash0hq/dash0-cli/issues/232), which asked for a `kubectl diff`-style command, and for `apply --dry-run` to gain a server-aware variant. The new `diff` command is this change's answer to both halves of that ask: it fetches each document's current state from Dash0 and computes the difference client-side against the local input, rather than leaving `--dry-run` local-only. This is a client-side diff, not a Kubernetes-style Server-Side Apply (SSA) dry-run — there is no field-ownership tracking and no server-computed defaulting/admission behavior; the server is only ever the source of the "before" state being compared against. + +## What Changes + +- Both `dash0 diff` and `apply --since` require `--experimental`/`-X` for now. `diff` is gated the standard way (`experimental.RequireExperimental`, the same mechanism every other experimental command already uses). `apply --since` is different: `apply` itself is a stable command, and gating the whole command would block its existing create/update behavior too. This requires a new mechanic — a flag-level experimental gate (`experimental.RequireExperimentalFlag(cmd, "since")`, checking whether the flag was actually passed before requiring `-X`) — since the existing `RequireExperimental` helper only gates whole commands (its own doc comment says so explicitly). `--force` needs no separate gate: it has no effect unless combined with `--since` (there's no other confirmation prompt in `apply` today), so it's covered transitively. `apply` without `--since` is completely unaffected — no gate, no behavior change, today's stable command as-is. +- Add a new top-level `dash0 diff` command: a read-only preview of what `apply` would do to a file or directory — creates, updates, and (when a git ref is given) deletions. It fetches each document's current state from Dash0 and computes the difference client-side (not a server-side-apply dry-run). Never writes. +- Add a `--since ` flag to `dash0 apply`, accepting any revision expression git itself accepts — a branch name, a tag, a commit SHA, or a relative expression like `HEAD~`; `--since` does not special-case or restrict which form `` takes. When present, `apply` also deletes assets whose identifier was present in the scanned scope at `` and is no longer present now, in addition to its existing create/update behavior. Detection operates on individual asset identifiers, not whole files: a multi-document YAML file or a `PrometheusRule` CRD with several rules can lose one identifier while the file itself still exists, and that asset is still detected as deleted. `--since` is accepted with both `-f ` and `-f ` for this reason — a single file is not automatically a deletion no-op. +- Deprecate `apply --dry-run` in favor of `dash0 diff`. `--dry-run` keeps working for backward compatibility — including combined with `--since`, where it still only previews (creates, updates, and deletions) and never writes — but each invocation prints a deprecation warning to stderr recommending `dash0 diff` instead, following the same pattern this CLI already uses for other deprecated flags (e.g. `teams update --name`). `--dry-run`'s output and exit-code behavior are not held to permanent parity with `diff`'s: `diff` is the actively-maintained, canonical way to preview a plan going forward, and `--dry-run` is legacy scaffolding kept working, not a twin implementation kept in lockstep. +- Git history reading is invoked directly by the `dash0` binary via plumbing commands (`git rev-parse`, `git diff-tree`, `git cat-file`, `git ls-tree`), not porcelain commands (`git diff`, `git show`, `git log`) — no separate helper tool, script, or GitHub Action is introduced. +- Deletion detection covers every asset kind `apply` already supports, dispatching to the matching `delete` command per kind (including a `PrometheusRule` CRD dispatching to both `check-rules delete` and `recording-rules delete` when it mixes alerting and recording rules). +- Add a `--force` flag to `apply` — it has none today, unlike every per-kind ` delete` command. Deletions triggered by `--since` go through the same per-asset confirmation prompt every other destructive command already uses, auto-skipped by `--force` or agent-mode — no new confirmation mechanism. +- Deletion is a plain identifier lookup: `--since` does not check whether the asset's current live state in Dash0 still matches what git last recorded for it before deleting — the same as every other destructive command in this CLI, none of which verify server state before deleting. +- `dash0 diff` uses a three-way exit code, matching `kubectl diff`'s convention rather than this CLI's usual uniform 0/1 exit code: `0` when there is nothing to report (no differences), `1` when at least one difference is found (create, update, or deletion pending), and `2` for a genuine error (e.g. an unresolvable `--since` ref, a deleted asset with no stable identifier, an authentication failure). This is a deliberate, narrow exception scoped to `diff` alone — `apply` (including its deprecated `--dry-run` flag) is unaffected and keeps this CLI's uniform 0/1 exit-code convention, since it mutates state and isn't the command explicitly modeled on `kubectl diff`. The exception exists because `diff` is directly answering issue #232's ask for `kubectl diff`-style ergonomics, and a CI script consuming `diff`'s exit code to decide "review pending changes" versus "something is broken" is exactly the automation use case a single uniform code would otherwise defeat. +- A deleted asset with no stable identifier (id or origin, depending on kind) fails the whole `--since` run, on both `apply` and `diff` — the same failure mode as an unresolvable ref. Silently skipping it would leave an orphaned asset with only a warning as the trace, which is the exact class of easy-to-miss failure this change exists to close. + +Not breaking: `dash0 apply -f ` without `--since` behaves exactly as it does today, with no `-X` requirement. `dash0 apply --dry-run` continues to work, with or without `--since`, except for the new deprecation warning on stderr (and, when combined with `--since`, the same `-X` requirement `--since` itself has). All new behaviors are opt-in, gated behind `--experimental` for now, or backward compatible. + +## Impact + +- Affected specs: `apply` (new requirement), `diff` (new capability). +- Affected commands: `dash0 apply`, new `dash0 diff` command. +- New runtime dependency: `git` must be on `PATH` when `--since` (on `apply` or `diff`) is used. Not required otherwise. The CLI's `ghcr.io/dash0hq/cli` Docker image (`FROM scratch`) does not include `git` — `--since`/`diff --since` are not supported from that image. Documentation must call this out explicitly, pointing users who need `--since` from a container at a different distribution channel (Homebrew, Nix, GitHub Releases) or at mounting a host `git` binary into the container. +- Prerequisite for `--since` in CI: the checkout step must fetch enough git history to resolve the given ref. GitHub Actions' default `actions/checkout` performs a shallow clone (depth 1), which will not contain the commit `--since` needs on the project's own motivating "every push" GitOps scenario unless `fetch-depth` is increased. For a fixed relative ref like `HEAD~1`, a small fixed depth (2) suffices, as `dash0-configuration`'s own workflow already demonstrates. For `github.event.before` specifically, the required depth is unbounded — it must cover however many commits the triggering push contained, which a workflow cannot know in advance — so the concrete recommendation is `fetch-depth: 0` (full history), not merely "increase it." +- Documentation must show the correct, safe `--since` invocation pattern for GitHub Actions: quote the interpolated value (`--since "${{ github.event.before }}"`) and gate it with an `if:` condition on the trigger. Malformed interpolation — an unquoted `${{ github.event.before }}` that expands empty with nothing following `--since`, or one that silently swallows a neighboring flag as the ref value — produces either a plain argument-parsing error or an ordinary-looking bad-ref error that `dash0` cannot distinguish from a genuine typo; see `design.md` for why this is documentation-only and not addressable in code. +- Out of scope for this change: external/pluggable diff tools (e.g. a `KUBECTL_EXTERNAL_DIFF` equivalent — dash0's existing built-in diff rendering already covers visualization), detecting deletions by comparing live server state against an origin label instead of git history (considered, rejected — see `design.md`), and a dedicated GitHub Action implementing git-diffing/deletion-detection logic, or a new "sync" verb. The actively-maintained surface is `diff` and `apply --since`; `apply --dry-run` is retained only for backward compatibility (deprecated in favor of `diff`), not developed further. A separate, thin GitHub Action that only handles correctly gating `--since` around GitHub's event-payload quirks (no git-diffing logic of its own) is a distinct concern, tracked in `openspec/changes/add-asset-synch-action` — and since it wraps `apply --since`, its own invocation must also pass `-X`/`--experimental` while the gate is in place. +- Promotion to stable: `docs/promoting-commands-to-stable.md` only documents whole-command promotion today. Promoting `diff` follows that guide unchanged; promoting `apply --since` needs the equivalent per-flag steps written down (remove the `RequireExperimentalFlag` call, drop `-X` from `--since` examples, add the backward-compat test for `-X` still working) — this change should extend that doc rather than leave the flag case undocumented for whoever promotes it later. diff --git a/openspec/changes/add-diff-and-since-flag/specs/apply/spec.md b/openspec/changes/add-diff-and-since-flag/specs/apply/spec.md new file mode 100644 index 00000000..236536f2 --- /dev/null +++ b/openspec/changes/add-diff-and-since-flag/specs/apply/spec.md @@ -0,0 +1,172 @@ +## ADDED Requirements + +### Requirement: `--since` requires `--experimental` +`--since` SHALL be gated behind `--experimental`/`-X`, using a new flag-level gate (`experimental.RequireExperimentalFlag(cmd, "since")`) rather than the existing whole-command gate — `apply` itself is not experimental and its create/update behavior is unaffected. The gate SHALL only fire when `--since` is actually passed; `apply` invocations that don't use `--since` SHALL NOT require `-X`, regardless of any other flag (including `--force` or `--dry-run`) being present. `--force` requires no gate of its own — it has no effect unless combined with `--since`, so it is covered transitively. + +#### Scenario: `--since` without `--experimental` fails +- **GIVEN** `--since ` is passed without `--experimental`/`-X` +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** the command fails before any git or API operation, with an error naming `--since` specifically and pointing at `--experimental`/`-X` + +#### Scenario: `--since` with `--experimental` proceeds normally +- **GIVEN** `--since ` is passed together with `--experimental`/`-X` +- **WHEN** `dash0 apply -f --since --experimental` runs +- **THEN** the command proceeds exactly as specified by the rest of this document — the gate does not otherwise alter behavior + +#### Scenario: `apply` without `--since` needs no gate +- **GIVEN** an ordinary `apply` invocation that does not pass `--since` +- **WHEN** `dash0 apply -f ` runs (with or without `--force`/`--dry-run`) +- **THEN** the command runs exactly as it does today, with no `--experimental` requirement + +Every other scenario in this document that exercises `--since` assumes `--experimental`/`-X` is already passed alongside it — the gate is this requirement's concern alone; the remaining requirements describe `--since`'s functional behavior once that precondition holds, and do not repeat it in every `WHEN` clause. + +### Requirement: Deletion-aware sync via `--since` +`dash0 apply -f ` SHALL accept an optional `--since ` flag, where `` is any revision expression git itself accepts — a branch name, a tag, a commit SHA, or a relative expression like `HEAD~`. When present, in addition to its existing create/update behavior, `apply` SHALL delete every asset whose identifier (id or origin, depending on kind) was present in the scanned scope at `` and is no longer present in the scanned scope's current disk contents — the same files `apply`'s existing create/update path already reads, not a second git-object read of the current commit. An uncommitted local deletion is therefore visible to `--since` the same way it's already visible to plain `apply` today. Detection SHALL operate on individual asset identifiers extracted from every document across the scanned scope, not on whole-file presence: a multi-document YAML file (documents separated by `---`) can lose one identifier while the file itself is unchanged or still exists, and that asset SHALL still be detected as deleted. `PrometheusRule` CRD identity is CRD-level for identifier-based detection, matching the identity model `apply` already uses for create/update (every alerting/recording rule converted from one CRD shares that CRD's single identifier). Removing an individual alerting rule from a CRD that still exists SHALL still be detected — by comparing the parsed rule list (group name + alert name) between `` and the current commit — and SHALL be deleted by resolving the removed alert's check rule by name (` - `) rather than by the CRD's shared identifier, since that identifier cannot distinguish between alerts within the same CRD. Removing an individual recording rule from a CRD that still exists is not a deletion: recording rules are never individually named or created as separate resources, so the reapplied CRD's normal update already reflects the removal. When `--since` is omitted, `apply` SHALL behave exactly as it does today (create/update only, no deletion detection). Detection SHALL compare only the identifier set at `` against the identifier set at the current commit, not scan intermediate commits: an asset added and then removed again entirely within that range SHALL be ignored — neither created, updated, nor deleted — since it is absent at both comparison points. + +#### Scenario: File removed from the directory since the given ref +- **GIVEN** a directory containing a file at git ref `` whose content yields one or more asset identifiers (e.g. a single-document file, a multi-document YAML file, or a `PrometheusRule` CRD with several rules) +- **AND** that file has since been deleted from the directory +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** every asset whose identifier came from that file is deleted from Dash0, subject to the confirmation requirement below + +#### Scenario: `--since` omitted preserves today's behavior +- **GIVEN** a directory with files added, modified, and removed since some earlier commit +- **WHEN** `dash0 apply -f ` runs without `--since` +- **THEN** only creates and updates are performed; no asset is deleted + +#### Scenario: Relative ref like `HEAD~1` is accepted +- **GIVEN** a directory containing a file at `HEAD~1` that has since been deleted +- **WHEN** `dash0 apply -f --since HEAD~1` runs +- **THEN** the corresponding asset is deleted from Dash0, the same as passing a branch name, tag, or commit SHA — `--since` does not special-case relative expressions + +#### Scenario: Document removed from a surviving multi-document file +- **GIVEN** a single multi-document YAML file that still exists after the change +- **AND** one of its documents, present at ``, has since been removed from that file +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** the asset corresponding to the removed document is deleted from Dash0, even though the file itself still exists + +#### Scenario: Alerting rule removed from a surviving PrometheusRule CRD +- **GIVEN** a `PrometheusRule` CRD file that still exists after the change +- **AND** one of its alerting rules, present at ``, has since been removed from that file, while other rules remain +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** the check rule named ` - ` for the removed alert is resolved by name and deleted, even though the CRD's shared identifier is still present and its remaining rules are updated as usual + +#### Scenario: Recording rule removed from a surviving PrometheusRule CRD is a plain update, not a deletion +- **GIVEN** a `PrometheusRule` CRD file that still exists after the change +- **AND** one of its recording rules, present at ``, has since been removed from that file, while other rules remain +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** no deletion is performed; the CRD's normal update already reflects the removal, since recording rules are never individually named or created as separate resources + +#### Scenario: Asset added and removed between `` and HEAD +- **GIVEN** an asset's identifier does not exist at ``, was added in a later commit, and was removed again before the current commit +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** that asset is neither created, updated, nor deleted — it is ignored, since it is absent at both `` and the current commit + +### Requirement: Deletion covers every asset kind `apply` supports +Deletion detection SHALL dispatch to the same per-kind `delete` operation `apply` already uses for that kind's create/update path, covering all kinds `apply` supports: `Dashboard`/`PersesDashboard`, `CheckRule`, `SyntheticCheck`, `View`, `Dash0SpamFilter`, `Dash0NotificationChannel`, `Dash0Team`, and `PrometheusRule` (recording and alerting rules). + +#### Scenario: Mixed PrometheusRule CRD deletion +- **GIVEN** a deleted file that was a `PrometheusRule` CRD containing multiple alerting rules and recording rules +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** both the corresponding check rules and the corresponding recording rules are deleted + +#### Scenario: Multiple alerting rules in one PrometheusRule CRD +- **GIVEN** a deleted file that was a `PrometheusRule` CRD containing multiple alerting rules +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** all the corresponding check rules are deleted + +#### Scenario: Multiple recording rules in one PrometheusRule CRD +- **GIVEN** a deleted file that was a `PrometheusRule` CRD containing multiple recording rules +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** all the corresponding recording rules are deleted + +### Requirement: Deletion does not verify the asset's live state against git history +`--since` SHALL delete an asset solely because its identifier is present in the scanned scope at `` and absent at the current commit. It SHALL NOT compare the asset's current state in Dash0 against the content git last recorded for that identifier before deleting it; there is no drift check. This matches every other destructive command in this CLI, none of which verify server state before deleting. + +#### Scenario: Asset modified out-of-band since last recorded in git +- **GIVEN** an asset's file was removed from the directory since `` +- **AND** the asset's current state in Dash0 differs from what was last recorded in git for that identifier (e.g. it was edited via the Dash0 UI after the last commit that touched it) +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** the asset is deleted anyway, subject only to the confirmation requirement below — no comparison against its live state is performed + +### Requirement: `apply` gains a `--force` flag; deletions require confirmation like any other destructive command +`dash0 apply` SHALL accept a new `--force` flag — `apply` has none today, unlike every per-kind ` delete` command. Each deletion triggered by `--since` SHALL go through the same confirmation prompt every ` delete` command already uses, skipped only when `--force` is passed or agent-mode is active. No separate confirmation mechanism is introduced for this feature. Declining an individual deletion's prompt SHALL skip only that asset — the rest of the `--since` run (other creates, updates, and deletions) continues. A run containing at least one declined deletion SHALL cause `apply` to exit with a non-zero status once it completes, since the desired end state (that asset gone) was not reached. + +#### Scenario: Interactive run without --force +- **GIVEN** `--since` detects one or more assets to delete +- **AND** `--force` is not passed and agent-mode is not active +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** the user is prompted to confirm each deletion individually before it happens + +#### Scenario: Non-interactive run with --force or agent-mode +- **GIVEN** `--since` detects one or more assets to delete +- **AND** `--force` is passed (or agent-mode is active) +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** each deletion proceeds without a prompt + +#### Scenario: Deletion prompt declined +- **GIVEN** `--since` detects one or more assets to delete, in an interactive run without `--force` +- **WHEN** the user declines the confirmation prompt for one of them +- **THEN** that asset is not deleted, the rest of the run continues, and the command exits with a non-zero status once complete + +### Requirement: Identity is by id/origin, never by local file path +An asset whose backing file still exists anywhere under the target directory (e.g. it was renamed or moved to a different subdirectory) SHALL NOT be deleted, even though git reports its old path as removed. Matching SHALL use the asset's `id`/`origin` label read from file content, not its file path. The target directory is a local discovery scope only and has no relationship to an asset's `dash0.com/folder-path` annotation or its placement in Dash0. + +#### Scenario: File renamed within the directory +- **GIVEN** an asset's file is moved from one subdirectory to another within the same target directory, with its id/origin unchanged +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** the asset is not deleted, and is updated in place if its content changed + +### Requirement: A deleted asset with no identifier fails the whole `--since` run +When a deleted document's content at `` carries no stable identifier (id or origin, depending on kind), `apply` SHALL fail the entire `--since` run before creating, updating, or deleting anything — the same failure mode as an unresolvable ref. It SHALL NOT skip that one deletion with only a warning and continue: a silently-skipped orphan is exactly the easy-to-miss failure this feature exists to close. + +#### Scenario: Deleted document never had an id +- **GIVEN** a deleted document whose content at `` has no `dash0.com/id` (or equivalent origin) set +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** the command exits with an error before creating, updating, or deleting anything, naming the offending document and the missing identifier + +### Requirement: An unresolvable `--since` ref fails the whole command +When `` cannot be resolved (a nonexistent ref, a too-shallow git history, git's all-zeros SHA sentinel, or an empty string), `apply` SHALL fail before creating, updating, or deleting anything. It SHALL NOT fall back to create/update-only. When `` is specifically git's all-zeros SHA sentinel (`0000000000000000000000000000000000000000`) or an empty string, the error message SHALL name the specific condition in CI-agnostic terms — the sentinel: "the ref is git's all-zeros SHA, meaning there's no prior commit to compare against — common on a branch's first push"; an empty string: "`--since` was passed an empty value, likely because this trigger has no prior-commit reference to supply" — and suggest skipping `--since` for that invocation or passing an explicit ref. The message names the git-level condition, not any specific CI provider's field name (e.g. GitHub's `github.event.before`), so it stays useful across CI providers; GitHub-specific framing belongs in `asset-synch`'s own documentation and preflight output (see `openspec/changes/add-asset-synch-action`), not in the core CLI's error text. The failure mode itself (whole command fails, no fallback) is otherwise identical to any other unresolvable ref. + +#### Scenario: Ref cannot be resolved +- **GIVEN** a `--since ` value that git cannot resolve to a commit +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** the command exits with an error and no asset is created, updated, or deleted + +#### Scenario: All-zeros SHA sentinel gets a specific, actionable error +- **GIVEN** `--since` is passed git's all-zeros SHA sentinel, e.g. from `github.event.before` on a branch's first push +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** the command exits with an error identifying the sentinel and recommending the caller skip `--since` for this invocation or pass an explicit ref, and no asset is created, updated, or deleted + +#### Scenario: Empty `--since` value gets a specific, actionable error +- **GIVEN** `--since` is passed an empty string, e.g. from a quoted `--since "${{ github.event.before }}"` on a trigger type that doesn't define `before` +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** the command exits with an error identifying the empty value and recommending the caller skip `--since` for this invocation or pass an explicit ref, and no asset is created, updated, or deleted + +### Requirement: A resolvable-but-non-ancestor `--since` ref requires confirmation, not a hard failure +When `` resolves to a real commit that is not an ancestor of the current commit (e.g. after a force-push or history rewrite on the tracked branch), `apply` SHALL NOT treat this the same as an unresolvable ref. It SHALL prompt for confirmation before computing the deletion plan, naming the likely cause (force-push or history rewrite) — skipped only when `--force` is passed or agent-mode is active, the same bypass every other destructive operation in this CLI already uses. When neither applies and no confirmation can be obtained (no terminal available), the command SHALL fail rather than proceed silently. + +#### Scenario: Non-ancestor ref prompts for confirmation +- **GIVEN** `--since ` resolves to a real commit that is not an ancestor of the current commit +- **AND** `--force` is not passed and agent-mode is not active +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** the user is prompted to confirm before any deletion plan is computed, and the prompt names the likely cause (force-push or history rewrite) + +#### Scenario: Non-ancestor ref proceeds with --force +- **GIVEN** `--since ` resolves to a real commit that is not an ancestor of the current commit +- **AND** `--force` is passed (or agent-mode is active) +- **WHEN** `dash0 apply -f --since ` runs +- **THEN** the command proceeds without prompting, computing the deletion plan against the resolved ref as usual + +### Requirement: `apply --dry-run` is deprecated in favor of `dash0 diff` +`--dry-run` on `apply` SHALL be marked deprecated in favor of `dash0 diff`. It SHALL continue to function for backward compatibility — including combined with `--since`, where it previews the plan (creates, updates, and deletions) without writing anything, the same as before this change — but each invocation SHALL print a deprecation warning to stderr recommending `dash0 diff` instead, following the same pattern used for other deprecated flags in this CLI (e.g. `teams update --name`). `apply --dry-run`'s output format and exit-code behavior are not held to permanent parity with `dash0 diff`'s exit-code convention; `dash0 diff` is the actively-maintained, canonical way to preview a plan going forward. + +#### Scenario: `--dry-run` still works but warns +- **GIVEN** any `dash0 apply -f --dry-run` invocation, with or without `--since` +- **WHEN** the command runs +- **THEN** it previews the plan as before (no writes), and a deprecation warning recommending `dash0 diff` is printed to stderr + +#### Scenario: `--dry-run --since` still only previews, never deletes +- **GIVEN** `--since ` detects one or more assets that would be deleted +- **WHEN** `dash0 apply -f --dry-run --since ` runs +- **THEN** the deletions are reported as part of the preview, and nothing is deleted, created, or updated diff --git a/openspec/changes/add-diff-and-since-flag/specs/diff/spec.md b/openspec/changes/add-diff-and-since-flag/specs/diff/spec.md new file mode 100644 index 00000000..23c67cc2 --- /dev/null +++ b/openspec/changes/add-diff-and-since-flag/specs/diff/spec.md @@ -0,0 +1,87 @@ +## ADDED Requirements + +### Requirement: `dash0 diff` requires `--experimental` +`diff` SHALL be gated behind `--experimental`/`-X` using the existing whole-command mechanism (`experimental.RequireExperimental`), the same as every other experimental command in this CLI — `[experimental]` prefixed on its `Short` description, `-X` shown in its `Example` lines. Every other scenario in this document assumes `--experimental`/`-X` is already passed; the gate is this requirement's concern alone and is not repeated in every `WHEN` clause below. + +#### Scenario: `diff` without `--experimental` fails +- **GIVEN** `--experimental`/`-X` is not passed +- **WHEN** `dash0 diff -f ` runs +- **THEN** the command fails before any API operation, naming `diff` and pointing at `--experimental`/`-X` + +### Requirement: New `dash0 diff` command +`dash0` SHALL provide a new top-level `diff` command accepting `-f `, the same input `apply` accepts. `diff` SHALL query Dash0 for each document's current state and report what `apply` would do, without writing anything. It SHALL accurately distinguish a create from an update, unlike the existing local-only `apply --dry-run`. + +#### Scenario: Preview a create +- **GIVEN** a document describing an asset that does not yet exist in Dash0 +- **WHEN** `dash0 diff -f ` runs +- **THEN** the asset is reported as would-be-created, and nothing is created in Dash0 + +#### Scenario: Preview an update +- **GIVEN** a document describing an asset that already exists in Dash0 with different content +- **WHEN** `dash0 diff -f ` runs +- **THEN** the difference between the current and proposed state is shown, and nothing is changed in Dash0 + +### Requirement: `diff --since ` previews deletions too +`dash0 diff -f --since ` SHALL preview the full plan `dash0 apply -f --since ` would execute, including which assets would be deleted, following the same identifier-based detection, kind coverage, identity-matching, and ref-resolution rules defined for `apply`'s `--since` flag. It SHALL NOT delete, create, or update anything. `--since` is accepted with `-f ` as well as `-f `: a surviving multi-document YAML file can still have an asset reported as would-be-deleted if one of its documents was removed, and a surviving `PrometheusRule` CRD can have an individual alerting rule reported as would-be-deleted (resolved by name) even though the CRD's shared identifier persists — per `apply`'s identifier-based and name-based detection. + +#### Scenario: Preview a deletion +- **GIVEN** a file whose content yields one or more asset identifiers was present under the directory at `` and has since been deleted +- **WHEN** `dash0 diff -f --since ` runs +- **THEN** every asset whose identifier came from that file is reported as would-be-deleted, and nothing is deleted in Dash0 + +#### Scenario: Unresolvable ref fails diff the same way it fails apply +- **GIVEN** a `--since ` value that git cannot resolve to a commit +- **WHEN** `dash0 diff -f --since ` runs +- **THEN** the command exits with code `2` and reports no plan + +#### Scenario: All-zeros SHA sentinel gets the same specific error as apply +- **GIVEN** `--since` is passed git's all-zeros SHA sentinel, e.g. from `github.event.before` on a branch's first push +- **WHEN** `dash0 diff -f --since ` runs +- **THEN** the command exits with code `2`, identifying the sentinel and recommending the caller skip `--since` for this invocation or pass an explicit ref, and reports no plan + +#### Scenario: Empty `--since` value gets the same specific error as apply +- **GIVEN** `--since` is passed an empty string, e.g. from a quoted `--since "${{ github.event.before }}"` on a trigger type that doesn't define `before` +- **WHEN** `dash0 diff -f --since ` runs +- **THEN** the command exits with code `2`, identifying the empty value and recommending the caller skip `--since` for this invocation or pass an explicit ref, and reports no plan + +### Requirement: A resolvable-but-non-ancestor `--since` ref surfaces a warning, not a blocking confirmation +Since `diff` never mutates anything, a non-ancestor ref does not need the confirmation `apply --since` requires. `diff` SHALL print a warning identifying the likely cause (force-push or history rewrite) and noting the resulting preview may be misleading, then proceed to compute and show the plan. + +#### Scenario: Non-ancestor ref warns but still previews +- **GIVEN** `--since ` resolves to a real commit that is not an ancestor of the current commit +- **WHEN** `dash0 diff -f --since ` runs +- **THEN** a warning is printed identifying the likely cause, and the plan is still computed and shown + +### Requirement: A document fetch failure aborts the whole plan, not just that document +If querying Dash0 for any single document's current state fails (e.g. a transient network error, an authentication failure, a 5xx response), `diff` SHALL abort computing the plan entirely and report the failure as an error. It SHALL NOT report a partial plan covering only the documents whose fetch succeeded — consistent with `apply`'s existing all-or-nothing validation gate, where all documents are validated before any are applied. + +#### Scenario: A fetch fails partway through a multi-document diff +- **GIVEN** a directory with multiple documents, where Dash0 returns a transient error while fetching the current state of one of them after others have already succeeded +- **WHEN** `dash0 diff -f ` runs +- **THEN** the command exits with code `2`, and no partial plan covering only the successfully-fetched documents is reported + +### Requirement: A deleted asset with no identifier fails diff too +`dash0 diff --since ` SHALL apply the same rule as `apply --since`: when a deleted document's content at `` carries no stable identifier, `diff` SHALL fail with an error before reporting any plan, rather than reporting a partial preview that omits that asset. + +#### Scenario: No-identifier deletion fails diff the same way it fails apply +- **GIVEN** a deleted document whose content at `` has no stable identifier (id or origin, depending on kind) +- **WHEN** `dash0 diff -f --since ` runs +- **THEN** the command exits with code `2` before reporting any plan, naming the offending document and the missing identifier + +### Requirement: Exit code distinguishes differences from errors, matching `kubectl diff` +`dash0 diff` SHALL use a three-way exit code, a deliberate exception to this CLI's usual uniform 0/1 convention, scoped to `diff` alone: `0` when there is nothing to report (the proposed state matches Dash0's current state exactly — nothing would be created, updated, or deleted), `1` when at least one difference is found (a create, update, or deletion pending), and `2` for a genuine error (e.g. an unresolvable `--since` ref, a deleted asset with no stable identifier, an authentication failure, a document fetch failure). This mirrors `kubectl diff`'s own exit-code convention, the command `diff` is explicitly modeled on, so a CI script can branch on "review pending changes" versus "something is broken" from the exit code alone. `apply` (including its deprecated `--dry-run` flag) is unaffected and keeps this CLI's uniform 0/1 convention. + +#### Scenario: No differences +- **GIVEN** every document matches its corresponding asset in Dash0 exactly and no deletions are pending +- **WHEN** `dash0 diff -f ` runs +- **THEN** it exits with code `0` + +#### Scenario: At least one difference +- **GIVEN** at least one document would be created, updated, or (with `--since`) deleted +- **WHEN** `dash0 diff` runs +- **THEN** it exits with code `1` + +#### Scenario: A genuine error exits with a distinct code +- **GIVEN** a genuine error condition (e.g. an unresolvable `--since` ref, a deleted asset with no stable identifier, an authentication failure, a document fetch failure) +- **WHEN** `dash0 diff` runs +- **THEN** it exits with code `2`, distinct from both the clean (`0`) and differences-pending (`1`) cases diff --git a/openspec/changes/add-diff-and-since-flag/tasks.md b/openspec/changes/add-diff-and-since-flag/tasks.md new file mode 100644 index 00000000..cca3911a --- /dev/null +++ b/openspec/changes/add-diff-and-since-flag/tasks.md @@ -0,0 +1,119 @@ +## 1. Shared git + identifier-diffing package + +- [x] 1.1 New package `internal/git/` (`plumbing.go`) wrapping git plumbing via `os/exec`: `git rev-parse --verify ^{commit}` (resolvability), `git merge-base --is-ancestor HEAD` (ancestry), `git cat-file -p :` (reading a file's content at a ref), `git ls-tree -r [-- ]` (enumerating a tree's entries, pathspec-scoped for `-f `/`-f `). No porcelain commands (`git diff`, `git show`, `git log`) — see `design.md`. **Deviation from the original plan:** `git diff-tree` is not used — the identity-based, full-enumeration two-point diff (1.5) needs the complete identifier set at each endpoint anyway (to handle renames-within-scope correctly, since identity is by id/origin, never path), so a changed-paths-only fast path would only be an optimization, not a correctness requirement. `design.md`'s plumbing list should be updated to match when docs are revisited (Section 8). +- [x] 1.2 Ref classification (`ref.go`, `Repo.ClassifyRef`): distinguish (a) all-zeros SHA sentinel (`RefAllZeros`), (b) empty string (`RefEmpty`), (c) resolves + is an ancestor of HEAD (`RefResolvedAncestor`), (d) resolves + is NOT an ancestor (`RefResolvedNonAncestor`), (e) does not resolve at all (`RefUnresolvable`). Each maps to a distinct caller-visible outcome per `specs/apply/spec.md`'s ref-resolution requirements. +- [x] 1.3 Identifier-set extraction (`dash0yaml.ExtractIdentifier` in `dash0-api-client-go`'s `yaml/identifier.go`, consumed by `internal/git/snapshot.go`'s `ingestDocuments`): given a directory or file, parse every YAML document (multi-document `---`-separated files included) and every `PrometheusRule` CRD, extracting the kind-appropriate identifier, honoring the non-uniform `dash0.com/id`/`dash0.com/origin` precedence per kind (per `CLAUDE.md`'s Origin vs ID conventions). **Moved out of `internal/asset/` into the API client library** (`github.com/dash0hq/dash0-api-client-go/yaml`) so it's reusable outside dash0-cli — this per-kind id/origin precedence dispatch didn't already exist in the client library (only low-level `GetID`/`GetOrigin` accessors on already-unmarshaled structs did); dash0-cli currently consumes it via a `replace` directive in `go.mod` pointing at the local `../dash0-api-client-go` checkout until the change is released and the dependency is bumped to a tagged version. +- [x] 1.4 For `PrometheusRule` CRDs specifically: extracts the list of `(group.name, alert.name)` tuples for alerting rules (needed for the name-based partial-removal detection in 1.6) — recording rules are skipped, since removing a `record:` entry from a surviving CRD is a plain update, not a deletion. **Deviation (bug fix, found via adversarial testing, not part of the original 15-finding review):** `internal/git/snapshot.go` originally called `dash0yaml.ExtractPrometheusAlertNames` (the sibling `dash0-api-client-go/yaml` package's version), which unmarshals into a `*string` struct field via `sigs.k8s.io/yaml` — that library's YAML→JSON→struct path resolves an unquoted YAML boolean literal (`Y`, `N`, `yes`, `no`, `on`, `off`, `true`, `false`, and case variants) to a real JSON boolean, then silently coerces it into the destination string as `"true"`/`"false"` instead of erroring. An alert genuinely named e.g. `Y` was therefore corrupted to `"true"` in both `--since`'s alert-tracking diff and the live check-rule name composed by `create`/`apply` (`internal/asset/prometheusrule.go`'s `composePrometheusRuleNames`, which called the same sibling function). Added `asset.ExtractPrometheusAlertNames` (`internal/asset/prometheusrule.go`), a node-based reimplementation using `gopkg.in/yaml.v3` that reads each name's literal scalar value directly off the YAML node tree — bypassing type resolution entirely, so no boolean/int/null coercion of any kind can occur — and switched both call sites (`internal/git/snapshot.go`'s `ingestDocuments` and `composePrometheusRuleNames`) to it. The sibling repo's `dash0yaml.ExtractPrometheusAlertNames` still has the underlying bug; not fixed here since it's out of this change's scope, but every consumer inside `dash0-cli` now goes through the fixed version instead. See `TestParseCheckRules_BooleanLiteralAlertNamePreserved` / `TestExtractPrometheusAlertNames_BooleanLiteralPreserved` (`internal/asset/prometheusrule_test.go`). +- [x] 1.5 Two-point diff (`internal/git/diff.go`'s `Diff`, fed by `snapshot.go`'s `BuildSnapshotFromRef`/`BuildSnapshotFromDisk`): given the identifier sets at `` (read via git) and at the current disk contents of the scanned directory (**not** a second git-object read of HEAD — `BuildSnapshotFromDisk` walks the same kind of directory scope `apply`'s existing `discoverFiles` path reads), computes the deletion candidate set — identifiers present at `` and absent now. +- [x] 1.6 Alerting-rule partial removal (`Diff`'s `PrometheusAlertsByIdentifier` comparison): for a `PrometheusRule` CRD that still exists (its CRD-level identifier is unchanged), diffs the `(group.name, alert.name)` list between `` and current disk contents to detect an individual alert that disappeared while others remain, without conflating it with a whole-CRD deletion. +- [x] 1.7 No-identifier deletion candidate (`Diff`'s `NoIdentifier` handling, keyed by the underlying file path via `NoIdentifierDoc.FilePath` rather than the multi-document-suffixed doc path): if a document at `` corresponding to a deletion candidate has no `dash0.com/id`/origin at all, and its file no longer exists on disk, this is surfaced distinctly (caller fails the whole run — see 4.7). +- [x] 1.8 Unit tests for all of the above (`plumbing_test.go`, `ref_test.go`, `snapshot_test.go`, `diff_test.go`) — ref classification, identifier-set diffing, multi-document YAML, PrometheusRule CRD-level vs per-alert-name diffing, the no-identifier file-path-vs-doc-path regression case, and identifier-survives-under-a-different-path (rename) producing no diff. **Built on ad hoc real git repos created inline (`testrepo_test.go`) rather than Section 2's fixtures**, since Section 2 (declarative YAML scenario fixtures) did not exist yet at the time — migrate these tests to `testutil.BuildGitScenario` per 2.11 (optional cleanup, not blocking). + +## 2. Shared git-repo scenario fixtures + +- [x] 2.1 Store each `--since` test scenario as a declarative `GitRepoFixture` YAML document under `internal/testutil/fixtures/git-scenarios/.yml` — **superseded the original zipped-repo design** (see `internal/testutil/gitscenario.go`'s doc comment): a checked-in zip is an opaque binary artifact with no readable diff and a separate generation step to keep in sync; a YAML description of the commit history *is* the fixture, is diffable in review, and needs no regeneration step. `spec.repo.commits` is an ordered list of `{label?, resetTo?, message, changes?}`, where each `changes` entry is `{op: add|modify|delete, name, content?}` — `op` is explicit rather than inferred from a repeated file name, so a commit's intent (add/modify/delete) reads correctly on its own; `BuildGitScenario` cross-checks `op` against the file's actual existence at that point and fails loudly on a mismatch (e.g. `add` on a file that already exists). `spec.sinceRef` names a commit's `label` or a literal ref (e.g. the all-zeros sentinel) to use as-is. +- [x] 2.1a Added `internal/testutil/git_repo_fixture.schema.json` (JSON Schema, draft 2020-12) for the `GitRepoFixture` format, and `TestGitScenarioFixtures_MatchSchema` in `internal/testutil/gitscenario_test.go` validates every checked-in `.yml` fixture against it (via `sigs.k8s.io/yaml`'s `YAMLToJSON` + `github.com/santhosh-tekuri/jsonschema/v6`, a new test-only dependency, Apache-2.0-licensed). Verified both that all 7 real fixtures pass and that a deliberately malformed fixture (an unknown field) is rejected with a precise error path. +- [x] 2.2 ~~Generation script~~ — not needed under the YAML design; there is nothing to generate. `internal/testutil.BuildGitScenario` interprets the YAML directly, replaying real `git init`/`commit`/etc. calls (via `os/exec`, `commit.gpgsign` disabled since these are scratch repos under `t.TempDir()`, not the developer's own) fresh on every test run. +- [x] 2.3 Shared test helper `internal/testutil.BuildGitScenario(t, name) (repoDir, ref string)` parses a named scenario's YAML and replays its commits into a fresh `t.TempDir()` repo, returning the repo path plus the scenario's designated ref (resolved from a commit label, or used as a literal ref e.g. the all-zeros sentinel). This is the one thing every test tier calls — no tier re-derives or hand-rolls its own git setup. +- [x] 2.4 Scenario `whole-file-deletion`: a commit with two asset files, followed by a commit removing one file entirely. +- [x] 2.5 Scenario `multi-document-partial-deletion`: a commit with a multi-document YAML file, followed by a commit removing one document while the file survives. +- [x] 2.6 Scenario `prometheus-alert-partial-deletion`: a commit with a `PrometheusRule` CRD containing 2 alerting rules sharing one `dash0.com/id`, followed by a commit removing one alert while the CRD (and its shared identifier) survives. +- [x] 2.7 Scenario `prometheus-recording-partial-removal`: the same shape for one alert + one recording rule, followed by a commit removing the recording rule — the assertion is "no deletion occurs, it's a plain update." +- [x] 2.8 Scenario `first-push-new-branch`: a minimal one-commit repo (still needed so `-f`'s target resolves inside a real git repo) paired with `sinceRef: "0000...0"` (the literal all-zeros SHA), since that value is a GitHub webhook artifact, not a real git object, and is unrelated to the repo's actual history. +- [x] 2.9 Scenario `non-ancestor-force-push`: a labeled commit (the designated ref), then a later commit's `resetTo` hard-resets back to an earlier labeled commit before continuing — simulating a force-push. Verified `merge-base --is-ancestor HEAD` exits non-zero while `` itself still resolves by SHA (its object is never garbage-collected in this fixture's lifecycle). +- [x] 2.10 Scenario `too-shallow-clone`: the fixture describes three commits (full history); the shallow clone (`git clone --depth 1 file:// `, `file://` required — local-path clones otherwise silently ignore `--depth`) is performed at test time by the test itself, not part of the fixture. Verified the oldest commit is unresolvable after such a clone. +- [ ] 2.11 Section 1's unit tests (1.8) and the integration tests (4.13) still use ad hoc inline git repos, not these fixtures — migrating them is optional cleanup, not required for Section 6 (which consumes these fixtures directly). `BuildGitScenario` itself has direct coverage in `internal/testutil/gitscenario_test.go` (one test per scenario, asserting the repo state and ref match each scenario's description above). + +## 3. Experimental gate mechanic + +Both `diff` (Section 5) and `apply --since` (Section 4) are gated behind `--experimental`/`-X` for now — see the rationale in `design.md`. `diff` uses the existing whole-command gate; `--since` needs a new flag-level one, since the existing helper's own doc comment says it only handles the whole-subcommand case. + +- [x] 3.1 Add `RequireExperimentalFlag(cmd *cobra.Command, flagName string) error` to `internal/experimental/`: returns `nil` immediately if `cmd.Flags().Changed(flagName)` is false (flag wasn't passed — nothing to gate); otherwise applies the same `--experimental`/`-X` check `RequireExperimental` does, with an error message naming the specific flag rather than the command. +- [x] 3.2 Unit tests mirroring `experimental_test.go`'s existing coverage for `RequireExperimental`: flag not passed at all (no error, regardless of `-X`), flag passed without `-X` (error naming the flag), flag passed with `-X` (no error). +- [x] 3.3 `apply`'s `RunE` calls `experimental.RequireExperimentalFlag(cmd, "since")` — only fires when `--since` was actually passed; `apply`'s existing create/update path and every other flag (including `--force`, `--dry-run`) remain fully ungated. +- [ ] 3.4 `diff`'s `RunE` calls the ordinary `experimental.RequireExperimental(cmd)`, `[experimental]` prefixed on `Short`, `-X` shown in every `Example` line — identical to every other experimental command. + +## 4. `apply --since` and `apply --force` + +- [x] 4.1 Add `--since ` flag and `--force` flag to `internal/apply/apply.go`'s flag struct (`apply` has no `--force` today, unlike every per-kind ` delete` command). +- [x] 4.2 Wire the flag validation: `--since` combined with `-f ` is accepted (no directory-only restriction) — a single file's own deletion detection degenerates naturally per 1.5/1.6, it is not a special-cased no-op. `-f -` (stdin) is explicitly rejected with `--since`, since there is no path for git to scope by. +- [x] 4.3 Gate `--since` behind `--experimental`/`-X` (Section 3.3) before any other `--since`-related logic runs. +- [x] 4.4 Ref-resolution failure handling (`internal/apply/since.go`'s `computeDeletionPlan`, using `internal/git`'s `ClassifyRef`): all-zeros sentinel and empty string get CI-agnostic, specific error messages (not naming any CI provider's field name); any other unresolvable ref gets a plain "could not be resolved" message. All cases fail the whole command before any create/update/delete. +- [x] 4.5 Non-ancestor ref: prompts for confirmation (`internal/confirmation.ConfirmDestructiveOperation`), naming the likely cause (force-push/history rewrite); skips the prompt when `--force` or agent-mode is active; fails if no confirmation can be obtained (no terminal). **Deviation (bug fix):** the prompt originally ran *inside* `computeDeletionPlan`, before any document was processed — a declined or unconfirmable ref therefore aborted the entire apply run, including ordinary creates/updates that have nothing to do with `--since`. `computeDeletionPlan` (`internal/apply/since.go`) no longer prompts at all; it just returns the plan plus a warning string. `runApply` (`internal/apply/apply.go`) now confirms once, right before calling `applyDeletions`, *after* every other document has already been applied — mirroring how a declined per-asset deletion (4.9) never blocks the rest of the run. See `TestApply_Since_NonAncestorRef_DeclinedDeletionDoesNotBlockCreates` / `TestApply_Since_NonAncestorRef_NoTerminalDoesNotBlockCreates` (`since_integration_test.go`) and `TestComputeDeletionPlan_NonAncestorRef_NeverPromptsOrErrors` (`since_test.go`, supersedes the three now-removed `TestComputeDeletionPlan_NonAncestorRef_*` confirmation tests). This restructuring also fixed the duplicate-warning bug (`applyDeletions` no longer prints `dp.warning` itself — `runApply` prints it once, immediately before the confirmation prompt). +- [x] 4.6 Deletion dispatch (`internal/apply/since.go`'s `deleteAssetByKindAndIdentifier`): for each deletion candidate, calls the matching per-kind delete API method directly (`Dashboard`/`PersesDashboard`, `CheckRule`, `SyntheticCheck`, `View`, `Dash0SpamFilter`, `Dash0NotificationChannel`, `Dash0Team` — no shared `internal/asset/` delete dispatcher existed, so this dispatch is new) or, for `PrometheusRule`, `deletePrometheusRuleCRD` deletes only from the endpoint(s) the CRD's content actually used. **Deviation (bug fix):** the original implementation unconditionally attempted DELETE on both `check-rules` and `recording-rules`, tolerating a 404 from whichever endpoint the CRD never used — but a 200 from that endpoint looks identical to "the CRD used it," so a coincidental id collision with an unrelated, still-live asset on the endpoint the CRD never used got silently deleted too. `internal/git.Snapshot` now records, per PrometheusRule identifier, which endpoint(s) its content used (`asset.PrometheusRuleEndpoints`, `Snapshot.PrometheusRuleEndpointsByIdentifier`), carried into `Deletion.PrometheusRuleEndpoints` by `Diff`, so `deletePrometheusRuleCRD` only calls the endpoint(s) actually in play (falling back to the old try-both-tolerate-404 behavior only if that information is entirely absent — e.g. a `Snapshot` built before this field existed). See `TestApply_Since_PrometheusRuleWholeCRDDeletion_AlertingOnlyDoesNotTouchRecordingRules` (`since_integration_test.go`) and `TestPrometheusRuleEndpoints_*` (`internal/asset/prometheusrule_test.go`). The residual non-atomicity for a genuinely mixed CRD (both endpoints legitimately used, second call fails after the first already succeeded) is inherent to the two-endpoint split and shared with the create/update path's own dual dispatch — not something `--since` can fix in isolation. + **Second deviation (known-limitation warning, not a code fix):** an ID-only `Dash0SpamFilter` (no `dash0.com/origin`) is not fully idempotent — the server reassigns its id on the first PUT (see `docs/commands.md`'s asset-identifiers table and `asset.ImportSpamFilter`'s doc comment) — so the id recorded in git history for `--since` may no longer match the filter's actual live id by the time it's deleted, and there is no local, API-free way to recover the live id from history alone. `Snapshot.SpamFilterUsesOriginByIdentifier` (populated via `asset.SpamFilterUsesOrigin`) is carried into `Deletion.SpamFilterUsesOrigin`, and `applyDeletions` prints a warning before deleting an ID-only spam filter, recommending `dash0.com/origin` for filters managed via `--since` (origin is never reassigned). See `TestApply_Since_SpamFilterIDOnlyDeletionWarns` / `TestApply_Since_SpamFilterOriginDeletionDoesNotWarn` (`since_integration_test.go`) and `TestSpamFilterUsesOrigin_*` (`internal/asset/spamfilter_test.go`). +- [x] 4.7 PrometheusRule alerting-rule name-based delete (`deleteCheckRuleByName`/`findCheckRuleIDByName`): for an individual alert removed from a surviving CRD (1.6), resolves the check rule by name (` - `) via `ListCheckRulesIter`-and-match (not by the CRD's shared identifier), then deletes by whatever id that lookup resolves to. +- [x] 4.8 No-identifier deletion candidate (1.7): `computeDeletionPlan` fails the entire `--since` run before creating, updating, or deleting anything, naming the offending document(s) and stating the missing identifier. +- [x] 4.9 Per-asset confirmation (reuse `confirmation.ConfirmDestructiveOperation`, same as every ` delete`); a declined deletion skips just that asset and the rest of the run continues (`applyDeletions`). +- [x] 4.10 Exit code: `apply` exits non-zero if the run completes with at least one declined deletion, even though the rest of the run succeeded (a deliberate divergence from a standalone ` delete`'s exit 0 on decline). +- [ ] 4.11 `--dry-run` deprecation: prints a deprecation warning to stderr on every `--dry-run` invocation (with or without `--since`), naming `dash0 diff` as the replacement. Behavior for `--dry-run` without `--since` is otherwise unchanged (local-only validation). **Postponed until Section 5 ships:** an earlier pass added this deprecation notice ahead of `dash0 diff` existing, which meant the CLI's own recommended next step (`dash0 diff`) returned `unknown command "diff"` — pointing users at an unimplemented command is worse than not deprecating yet. Reverted `apply.go`'s `Long`/`Example`/flag description and the runtime warning back to plain (non-deprecated) `--dry-run` text. Re-add the deprecation notice (warning + docs) once `dash0 diff` (Section 5) actually ships, at that point naming it directly. +- [x] 4.12 `--dry-run --since`: previews the deletion plan (`printDeletionPreview`, via Section 1's identifier diffing — no Dash0 API calls) alongside the existing local create/update validation; never writes, never prompts for confirmation on the non-ancestor case (warns instead, matching `diff`'s posture). +- [x] 4.13 Unit tests (`internal/apply/since_test.go`): `--since` experimental-gate check exercised through `apply` itself, stdin-rejection, ref-classification error messages, `computeDeletionPlan`'s non-ancestor-ref contract (returns a warning, never prompts — see the 4.5 deviation note for where the confirmation flow itself is now tested), no-identifier hard-fail, successful whole-file plan computation, `--since ""` (explicitly empty) hitting the `RefEmpty` error through the CLI entry point. +- [x] 4.14 Integration tests (`internal/apply/since_integration_test.go`, mock server + ad hoc git repos — Section 2's fixtures don't exist yet, see 2.11): whole-file deletion, multi-document YAML partial deletion, PrometheusRule alerting-rule partial deletion (name-based), PrometheusRule recording-rule partial removal (asserted as *not* a deletion — no call to the recording-rules endpoint at all), unresolvable ref, declined-deletion non-zero exit, `--dry-run --since` preview making no API call — all run with `--experimental` set. +- [ ] 4.15 Roundtrip tests — see Section 7. End-to-end (real binary + real `git`) tests — see Section 6. + +## 5. New `dash0 diff` command + +- [ ] 5.1 New package (e.g. `internal/diff/`) with `diff_cmd.go`, following `internal/apply/apply.go`'s structure (it spans the same file/directory scope and every asset kind, unlike a single-asset-type CRUD command). +- [ ] 5.2 Accept `-f ` and `--since ` (reusing Section 1's package — no duplicated git logic). +- [ ] 5.3 Gate the whole command behind `--experimental`/`-X` (Section 3.4) as the first thing in `RunE`. +- [ ] 5.4 For each document, fetch current state from Dash0 (per-kind Get calls, reusing existing per-asset Get logic) and diff locally against the proposed content (reuse `internal/asset/diff.go`'s `marshalForDiff`/`PrintDiff` used by `update` commands) to accurately distinguish create vs update. +- [ ] 5.5 All-or-nothing fetch gate: if any document's fetch fails, abort computing the plan entirely — no partial plan covering only the documents that succeeded. +- [ ] 5.6 `--since` deletion preview: reuse Section 1's identifier diffing (no API calls needed for this part) to report deletion candidates alongside creates/updates. Never delete, create, or update anything. +- [ ] 5.7 Ref-resolution errors mirror `apply`'s (4.4), same CI-agnostic messages, same all-zeros/empty/unresolvable classification. +- [ ] 5.8 Non-ancestor ref: print a warning (not a blocking confirmation, since `diff` never mutates) identifying the likely cause, then still compute and show the plan. +- [ ] 5.9 No-identifier deletion candidate: fail with an error before reporting any plan (same posture as `apply`). +- [ ] 5.10 Exit code plumbing: `diff` needs a three-way exit code (`0` clean, `1` differences pending, `2` genuine error) — a deliberate, narrow exception to this CLI's uniform 0/1 convention used everywhere else. This requires a mechanism in `cmd/dash0/main.go` beyond the existing "any `RunE` error → exit 1" path; likely a typed sentinel/wrapper error (e.g. a `diffFoundError` distinct from a genuine failure) that `main()` inspects to choose between exit 1 and exit 2, without changing exit-code behavior for any other command. +- [ ] 5.11 Human-mode output: diff report (creates/updates/deletes) vs `Error:`-prefixed message. Agent-mode output: existing structured JSON error envelope vs a diff-result object. +- [ ] 5.12 Register `dash0 diff` in `cmd/dash0/main.go`'s `init()`. +- [ ] 5.13 Unit tests: experimental-gate check (3.2's coverage exercised through `diff` itself), create-vs-update classification, all-or-nothing fetch gate, exit-code selection logic (0/1/2) in isolation from `main()`. +- [ ] 5.14 Integration tests (mock server + Section 2 scenario fixtures): preview-a-create, preview-an-update, preview-a-deletion (whole file, multi-document, PrometheusRule alerting rule), all-zeros/empty/unresolvable/non-ancestor ref handling, fetch-failure abort, all three exit codes end-to-end (`cmd.Execute()` + checking the process exit code, not just the returned error) — all run with `-X` set. +- [ ] 5.15 Roundtrip tests — see Section 7. End-to-end (real binary + real `git`) tests — see Section 6. +- [ ] 5.16 Once `dash0 diff` is registered and working (5.12), restore `apply`'s `--dry-run` deprecation notice (see 4.11's postponement note): the `Long`/`Example`/flag-description text in `internal/apply/apply.go` and the runtime stderr warning printed when `--dry-run` is passed, this time naming `dash0 diff` directly since it will actually exist. + +## 6. End-to-end tests via testcontainers + +Everything above tests either in-process (unit tests share the Go test binary; integration tests use a real temp git repo but still run in-process against a mocked HTTP server) or against a real Dash0 backend (Section 7). Neither tier proves that the actual `dash0` binary, invoking the actual `git` binary across a real process boundary, behaves correctly end-to-end — which matters here specifically because `--since` shells out to `git` rather than using a Go git library. This tier closes that gap without needing live Dash0 credentials. + +**Scope note:** `dash0 diff` (Section 5) does not exist yet, so this tier currently covers `apply --since` only. `diff --since` e2e coverage is deferred until Section 5 lands. + +- [x] 6.1 Added `github.com/testcontainers/testcontainers-go` v0.40.0 as a test-only dependency (MIT-licensed; confirmed via its `LICENSE` file). It's imported only from `//go:build e2e`-tagged files, so it never links into the production `dash0` binary and needs no entry in `docs/code-style.md`'s production-dependency table. +- [x] 6.2 `test/e2e/Dockerfile`: a minimal Alpine image with `git` installed and `/work` pre-created. The `dash0` binary is *not* baked in at Docker-build time by `make build` — `test/e2e/setup_test.go` cross-compiles a linux binary for the container's architecture on the host first (so the module's `go.mod` replace directive, a local sibling-directory checkout, resolves normally, which it would not inside a Docker build context scoped to this repo alone), then builds the image once per test binary run (`sync.Once`), tagged `dash0-cli-e2e-test:latest`. + **TODO (follow-up, not blocking):** once the `go.mod` replace directive (1.3) is gone, revisit moving the `go build` cross-compile out of `doBuildE2EImage` (`test/e2e/setup_test.go`) and into a multi-stage `test/e2e/Dockerfile` (`RUN go build` inside the image). Doing it now would require BuildKit's `--build-context` to hand the sibling `../dash0-api-client-go` checkout to the Docker build, which is more Dockerfile complexity, a harder local-Docker-version requirement, and a colder build cache than the current host-side compile buys back — not worth it while the replace directive is still temporary. +- [x] 6.3 Test harness (`test/e2e/since_e2e_test.go`, `test/e2e/setup_test.go`): for each scenario, starts a container from the pre-built image with `testcontainers.WithHostPortAccess(mockServerPort)`, copies the scenario's fixture repo in via `CopyDirToContainer`, marks it git-safe (see the ownership note below), then `container.Exec(...)`s the actual `dash0 apply --since --experimental` invocation against a `testutil.NewMockServer` reachable at `http://host.testcontainers.internal:`, asserting on exit code and combined output. +- [x] 6.4 Covers every Section-2 scenario end-to-end against `apply --since` (all passing): whole-file deletion, multi-document partial deletion, PrometheusRule alerting-rule partial deletion (name-based lookup), PrometheusRule recording-rule partial removal (no deletion), first-push-new-branch (all-zeros sentinel), non-ancestor force-push (both the no-`--force`/no-terminal hard-fail and the `--force` bypass, as subtests), too-shallow clone (the `--depth 1` clone is performed inside the container at test time, per 2.10). **Bug found and worked around**: `docker cp` preserves the host file owner's UID, which doesn't match the container's root user, tripping git's post-CVE-2022-24765 "dubious ownership" guard — the exact failure mode a real CI checkout with a UID mismatch would hit. Worked around in the harness the same way `actions/checkout` does (`git config --global --add safe.directory '*'` after copying), documented in `docs/testing.md`, rather than silently weakening the protection inside `dash0` itself — worth a product decision on whether `dash0`'s own git wrapper should do something similar for real-world containerized CI use. +- [x] 6.5 New Makefile target `make test-e2e`, gated on `docker version` succeeding first (clear error otherwise), kept separate from `make test-unit`/`make test-integration`. Documented the colima-specific `DOCKER_HOST`/`TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE` workaround needed locally (testcontainers-go's Docker auto-detection doesn't recognize colima's non-standard socket forwarding) in both the Makefile comment and `docs/testing.md`. +- [x] 6.6 Wired `make test-e2e` into `.github/workflows/ci.yml` as a dedicated `test-e2e` job. GitHub-hosted `ubuntu-latest` runners have a working Docker daemon natively, so no Docker-in-Docker or special runner capability was needed. **Not yet verified in a real CI run**: this branch's `go.mod` replace directive (Section 1, pending a `dash0-api-client-go` release) points at a local sibling-directory checkout that won't exist on a CI runner, so the *entire* CI pipeline — not just this job — will fail until that replace directive is removed. + +## 7. Roundtrip tests + +- [ ] 7.1 `test/roundtrip/test_apply_since_roundtrip.sh`: against a real Dash0 environment and a real git repo (create commits, run `dash0 apply --since --experimental`), covering at minimum a whole-file deletion, a multi-document YAML partial deletion, and a PrometheusRule alerting-rule partial deletion (name-based lookup) — the three deletion mechanisms from Sections 1 and 4. +- [ ] 7.2 `test/roundtrip/test_apply_since_idempotency.sh`: run `apply --since --experimental` once (applying the pending creates/updates/deletes), commit the resulting state as the new baseline, then run `apply --since --experimental` again against it — the second run reports no changes, proving `--since` doesn't repeat a deletion or error on an already-gone asset. Mirrors this project's existing `test_apply__idempotency.sh` pattern for asset creates/updates (see `docs/testing.md`). +- [ ] 7.3 `test/roundtrip/test_apply_since_ref_edge_cases.sh`: the all-zeros SHA sentinel (first push to a new branch in the test fixture) and a non-ancestor ref (simulate a force-push in the fixture repo), confirming the specific error messages and the confirmation-prompt/`--force`-bypass behavior against a real Dash0 environment, not just at the unit/e2e level. +- [ ] 7.4 `test/roundtrip/test_diff_roundtrip.sh`: against a real Dash0 environment, covering a create preview, an update preview, and a `--since` deletion preview (all invocations passing `--experimental`), confirming `dash0 diff`'s exit code (`0`/`1`/`2`) at each step and that nothing is ever written. +- [ ] 7.5 Register all four new scripts in `test/roundtrip/run_all.sh`'s `API_TESTS` list (per `docs/testing.md` — CI discovers `test_*.sh` automatically, but the project's convention is to register explicitly too). +- [ ] 7.6 `make test-roundtrip` passes with the new scripts included. +- [ ] 7.7 A backward-compat-style scenario (mirroring `Test_BackwardCompatWithExperimentalFlag` from `docs/promoting-commands-to-stable.md`) is not needed yet — that pattern applies at promotion time, once `-X` is no longer required. Note it here so whoever promotes `--since`/`diff` to stable knows to add it then. + +## 8. Documentation + +- [ ] 8.1 `docs/commands.md`: add a `diff` section under the appropriate taxonomy row (alongside `apply`, since it spans multiple asset kinds rather than being a single-kind CRUD command — update the taxonomy table if `diff` needs its own row) with the `[experimental]` framing and `-X` shown in every example, matching the convention used for `otlp proxy`/`teams`/etc.; update the `apply` section for `--since` (with its own `-X` requirement, distinct from the rest of the stable `apply` command), `--force`, the `--dry-run` deprecation note, and exit codes. +- [ ] 8.2 `README.md`: add `dash0 diff` to the command overview if the top-level command list changes (per `docs/documentation.md`'s README/`docs/about.md` sync rule if applicable), with the same experimental framing. +- [ ] 8.3 `internal/skill/gen`: add `diff` (and the `apply --since`/`--force` additions) to the topic map per `docs/agent-skill-maintenance.md`; run `make skill-bundle` and commit the regenerated `internal/skill/content/references/*.md`; update `SKILL.md`'s topic index. +- [ ] 8.4 Document the Docker distribution limitation (`ghcr.io/dash0hq/cli` has no `git`) in the relevant install/usage docs. +- [ ] 8.5 Document the correct, safe GitHub Actions `--since` invocation pattern (quoting, `if:` gating, and the current `-X` requirement) for users not using `asset-synch` — cross-reference `openspec/changes/add-asset-synch-action` for the convenience-action alternative. +- [ ] 8.6 Changelog entries (`make chlog-new`) for: the new `diff` command, `apply --since`/`--force`, and the `apply --dry-run` deprecation — all noting the `-X` requirement where applicable. +- [ ] 8.7 Extend `docs/promoting-commands-to-stable.md` with the flag-level promotion case (this doc currently only covers whole-command promotion): removing a `RequireExperimentalFlag` call instead of `RequireExperimental`, dropping `-X` from just the affected flag's examples rather than the whole command's, and the equivalent backward-compat test shape (7.7). This is a project-convention update, not just documentation for this one feature — the next feature needing a flag-level gate will look here. + +## 9. Verification + +- [ ] 9.1 `make build` succeeds. +- [ ] 9.2 `make test` passes (unit + integration). +- [ ] 9.3 `make test-e2e` passes (see Section 6). +- [ ] 9.4 `make test-roundtrip` passes (see Section 7). +- [ ] 9.5 `make lint` passes. +- [ ] 9.6 `./dash0 diff --help` shows `[experimental]` framing; `./dash0 apply --help` shows `--since`/`--force` with no `[experimental]` prefix on the command itself (only `--since`'s own behavior is gated, not `apply`'s help text). +- [ ] 9.7 `./dash0 --agent-mode diff --help` / `./dash0 --agent-mode apply --help` show correct JSON help. +- [ ] 9.8 `./dash0 apply -f --since ` (no `-X`) fails with an error naming `--since` specifically; `./dash0 diff -f ` (no `-X`) fails with the standard experimental-command error; both succeed once `-X`/`--experimental` is added. +- [ ] 9.9 Manual smoke test against a real Dash0 environment and a real git repo: `diff --experimental`, `apply --since --experimental`, `apply --dry-run --since --experimental`, and the all-zeros/empty/non-ancestor ref cases. +- [ ] 9.10 `make skill-validate` passes (part of `make lint`). From f0da9681c01a0294d9be027d39db640e06e38606 Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 17 Aug 2026 11:48:38 +0200 Subject: [PATCH 05/42] test(apply): add declarative git-scenario fixtures and testcontainers e2e harness Replaces the originally-planned zipped-repo fixture design with a declarative GitRepoFixture YAML format (internal/testutil/gitscenario.go) describing commit history directly -- readable and diffable in review, with no separate generation step. Validated against a new JSON Schema (git_repo_fixture.schema.json) in gitscenario_test.go. Adds the testcontainers-go-based end-to-end tier (test/e2e) that runs the real dash0 binary against a real git binary inside a container, the one gap unit and integration tests can't cover for --since, which shells out to git rather than using a Go git library. Wired into a new `make test-e2e` target and CI job. Renames internal/testutil/fixtures/apply's readme.txt placeholders to .gitkeep with explanatory comments, matching the project's convention. --- .github/workflows/ci.yml | 19 ++ .gitignore | 3 + Makefile | 19 +- docs/testing.md | 55 ++++ go.mod | 46 ++- go.sum | 121 +++++++- .../apply/error-empty-dir-nested/foo/.gitkeep | 3 + .../error-empty-dir-nested/foo/readme.txt | 2 - .../fixtures/apply/error-empty-dir/.gitkeep | 3 + .../fixtures/apply/error-empty-dir/readme.txt | 2 - .../git-scenarios/first-push-new-branch.yml | 22 ++ .../multi-document-partial-deletion.yml | 45 +++ .../git-scenarios/non-ancestor-force-push.yml | 53 ++++ .../prometheus-alert-partial-deletion.yml | 47 +++ .../prometheus-recording-partial-removal.yml | 49 +++ .../git-scenarios/too-shallow-clone.yml | 51 ++++ .../git-scenarios/whole-file-deletion.yml | 37 +++ .../testutil/git_repo_fixture.schema.json | 93 ++++++ internal/testutil/gitscenario.go | 181 +++++++++++ internal/testutil/gitscenario_test.go | 163 ++++++++++ test/e2e/Dockerfile | 12 + test/e2e/setup_test.go | 96 ++++++ test/e2e/since_e2e_test.go | 286 ++++++++++++++++++ 23 files changed, 1387 insertions(+), 21 deletions(-) create mode 100644 internal/testutil/fixtures/apply/error-empty-dir-nested/foo/.gitkeep delete mode 100644 internal/testutil/fixtures/apply/error-empty-dir-nested/foo/readme.txt create mode 100644 internal/testutil/fixtures/apply/error-empty-dir/.gitkeep delete mode 100644 internal/testutil/fixtures/apply/error-empty-dir/readme.txt create mode 100644 internal/testutil/fixtures/git-scenarios/first-push-new-branch.yml create mode 100644 internal/testutil/fixtures/git-scenarios/multi-document-partial-deletion.yml create mode 100644 internal/testutil/fixtures/git-scenarios/non-ancestor-force-push.yml create mode 100644 internal/testutil/fixtures/git-scenarios/prometheus-alert-partial-deletion.yml create mode 100644 internal/testutil/fixtures/git-scenarios/prometheus-recording-partial-removal.yml create mode 100644 internal/testutil/fixtures/git-scenarios/too-shallow-clone.yml create mode 100644 internal/testutil/fixtures/git-scenarios/whole-file-deletion.yml create mode 100644 internal/testutil/git_repo_fixture.schema.json create mode 100644 internal/testutil/gitscenario.go create mode 100644 internal/testutil/gitscenario_test.go create mode 100644 test/e2e/Dockerfile create mode 100644 test/e2e/setup_test.go create mode 100644 test/e2e/since_e2e_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72da6d59..50a3ac78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,25 @@ jobs: path: build/dash0 retention-days: 1 + test-e2e: + # Real dash0 binary + real git binary inside a container, proving + # --since's git-shell-out path works across a real process boundary -- + # something in-process unit/integration tests can't cover. GitHub-hosted + # ubuntu-latest runners have a working Docker daemon natively, so no + # special runner capability (unlike self-hosted Docker-in-Docker) is + # needed here. + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Run end-to-end tests + run: make test-e2e + lint-go: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index e6be1f61..9c4a1b93 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ bin/ # Built binaries build/ +# Cross-compiled binary for the test-e2e Docker image (see test/e2e/setup_test.go) +test/e2e/dash0 + # Tools binaries .tools/ diff --git a/Makefile b/Makefile index 7ab6c8a3..1a3929a9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build clean test test-unit test-integration test-roundtrip install lint lint-install lint-go-install lint-sh-install lint-go lint-sh chlog-install chlog-new chlog-validate chlog-preview chlog-update update-vendor-hash update-flake-lock skill-bundle skill-validate +.PHONY: all build clean test test-unit test-integration test-roundtrip test-e2e install lint lint-install lint-go-install lint-sh-install lint-go lint-sh chlog-install chlog-new chlog-validate chlog-preview chlog-update update-vendor-hash update-flake-lock skill-bundle skill-validate all: lint test @@ -17,7 +17,7 @@ CHLOGGEN=$(TOOLS_BIN_DIR)/chloggen build: (mkdir -p $(BUILD_DIR) || true) && go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/dash0 -test: test-unit test-integration test-roundtrip +test: test-unit test-integration test-e2e test-roundtrip test-unit: go test -v ./... @@ -28,6 +28,21 @@ test-integration: test-roundtrip: build bash test/roundtrip/run_all.sh +# End-to-end tests: the real dash0 binary + the real git binary inside a +# container, proving --since's git-shell-out path works across a real +# process boundary (in-process unit/integration tests can't). Gated behind +# Docker being available and kept separate from test-unit/test-integration +# given the added runtime cost and the Docker dependency. +# +# Colima users: testcontainers-go's Docker auto-detection doesn't recognize +# colima's non-standard socket forwarding. Export these first: +# export DOCKER_HOST="unix://$$HOME/.colima/default/docker.sock" +# export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE="/var/run/docker.sock" +test-e2e: + @command -v docker >/dev/null 2>&1 || { echo "Error: docker is required for test-e2e" >&2; exit 1; } + @docker version >/dev/null 2>&1 || { echo "Error: docker daemon is not reachable (is it running?)" >&2; exit 1; } + go test -v -tags=e2e ./test/e2e/... + install: build cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/ diff --git a/docs/testing.md b/docs/testing.md index 1710126e..87fc2880 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -60,3 +60,58 @@ They create assets, read them back, verify the output, and clean up. - When adding a new signal command (e.g., `metrics send`), add a send-and-query roundtrip. - Register every new test script in `run_all.sh` (in `API_TESTS` or `OTLP_TESTS`). CI discovers test scripts automatically by scanning `test/roundtrip/test_*.sh`. + +# End-to-End Tests + +End-to-end tests run the real `dash0` binary against a real `git` binary inside a container, using [testcontainers-go](https://golang.testcontainers.org/). +They exist to prove that code shelling out to `git` (see `internal/git/`) works across a real process boundary — something neither unit tests (in-process) nor integration tests (a real temp git repo, but still in-process against a mocked HTTP server via `httptest`) can cover. +They need no live Dash0 credentials: the mock API server runs on the host and is exposed to the container via testcontainers-go's [`WithHostPortAccess`](https://pkg.go.dev/github.com/testcontainers/testcontainers-go#WithHostPortAccess), reachable at `http://host.testcontainers.internal:`. + +## Running +- Requires Docker (or a Docker-compatible daemon) running locally. +- Run: `make test-e2e`. +- **Colima users**: testcontainers-go's Docker auto-detection does not recognize colima's non-standard socket forwarding. Export these first: + ```bash + export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock" + export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE="/var/run/docker.sock" + ``` +- GitHub-hosted `ubuntu-latest` CI runners have a working Docker daemon natively, so `test-e2e` needs no special CI runner capability, unlike a self-hosted Docker-in-Docker setup. + +## Structure +- `test/e2e/Dockerfile` — a minimal Alpine image with `git` installed; never shipped, used only by this test tier. +- `test/e2e/setup_test.go` — cross-compiles a linux binary for the container's architecture (on the host, so the module's local `go.mod` replace directives, if any, resolve normally) and builds the image once per test binary run. +- `test/e2e/since_e2e_test.go` — one test per `--since` scenario fixture (see [Shared git-repo scenario fixtures](#shared-git-repo-scenario-fixtures) below), each starting a fresh container, copying the scenario's repo in, and asserting on `dash0`'s exit code and output. +- All files are tagged `//go:build e2e`, so `go build`/`go test` skip them unless `-tags e2e` is passed (as `make test-e2e` does). + +## A note on container file ownership + +`docker cp` (used to copy a scenario's repo into the container) preserves the host file owner's UID, which does not match the container's root user. +This trips git's "dubious ownership" guard (the fix for CVE-2022-24765) the same way a real CI environment can when a checkout is owned by a different UID than the one running commands — `actions/checkout` works around exactly this by marking the checkout safe. +The e2e harness does the same (`git config --global --add safe.directory '*'` inside the container after copying) rather than disabling the protection inside `dash0` itself. + +## When to Add End-to-End Tests +- When adding a new `--since`/`--diff`-style scenario fixture (see below), add a matching `TestE2E_*` case. +- Scope new coverage to commands that actually shell out to `git`; commands that only call the Dash0 API are already covered by integration tests. + +# Shared Git-Repo Scenario Fixtures + +`--since`-related tests (unit, integration, and end-to-end) share one set of git-repo fixtures rather than each tier hand-rolling its own git setup. + +## Fixture Location +- Checked-in fixtures: `internal/testutil/fixtures/git-scenarios/.yml`, one `GitRepoFixture` document per scenario. +- Each fixture declaratively lists the commits to replay: `spec.repo.commits` is an ordered list, each with a `message`, an ordered list of `changes` to apply, an optional `label` naming the commit for later reference, and an optional `resetTo` that hard-resets to a labeled commit first (used to simulate a force-push). `spec.sinceRef` is the `--since` value the scenario is meant to be tested with: either a commit's `label` or a literal ref (e.g. git's all-zeros sentinel) used as-is. +- Each entry in `changes` has an explicit `op` (`add`, `modify`, or `delete`), a file `name`, and (for `add`/`modify`) its new full `content`. `op` is deliberately explicit rather than inferred from the same file name reappearing with different content in a later commit: a commit's intent reads correctly on its own, and `BuildGitScenario` cross-checks it against the file's actual existence at that point in history (e.g. `add` on a file that's already there, or `modify`/`delete` on one that doesn't exist yet, fails loudly with a message naming the likely correct `op`). +- There is no generation step or binary artifact to keep in sync: the fixture *is* the repo's history, in a form that's readable and diffable directly in review. A test builds the real repo from it fresh, every run. +- `internal/testutil/git_repo_fixture.schema.json` is the JSON Schema for this format; `TestGitScenarioFixtures_MatchSchema` in `internal/testutil/gitscenario_test.go` validates every checked-in fixture against it. + +## Go Helper +`internal/testutil.BuildGitScenario(t, name) (repoDir, ref string)` parses a named scenario's YAML and replays its commits into a fresh `t.TempDir()` repo, returning the repo path plus the resolved ref to pass as `--since`. This is the one thing every test tier calls. + +## Scenarios +- `whole-file-deletion` — a file is removed entirely between the ref and HEAD. +- `multi-document-partial-deletion` — one document is removed from a multi-document YAML file; the file survives. +- `prometheus-alert-partial-deletion` — one alerting rule is removed from a `PrometheusRule` CRD; the CRD (and its shared `dash0.com/id`) survives. +- `prometheus-recording-partial-removal` — the same shape for a recording rule; the correct behavior is a plain update, not a deletion (there is no per-record identity to diff). +- `first-push-new-branch` — a minimal one-commit repo paired with the literal all-zeros SHA as the ref, simulating a branch's first push. +- `non-ancestor-force-push` — a commit is orphaned by a simulated force-push (`git reset --hard` + a new commit); it still resolves by SHA but is not an ancestor of HEAD. +- `too-shallow-clone` — the checked-in fixture carries full history; a `--depth 1` clone (performed by the test itself, via `file://`, not baked into the zip) makes the older ref unresolvable. diff --git a/go.mod b/go.mod index ac0e3706..c5c64e25 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,11 @@ require ( github.com/google/uuid v1.6.0 github.com/muesli/termenv v0.16.0 github.com/pmezard/go-difflib v1.0.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.12.1 + github.com/testcontainers/testcontainers-go v0.40.0 go.opentelemetry.io/collector/component v1.65.0 go.opentelemetry.io/collector/config/configgrpc v1.65.0 go.opentelemetry.io/collector/config/confighttp v0.159.0 @@ -20,8 +22,8 @@ require ( go.opentelemetry.io/collector/pdata v1.65.0 go.opentelemetry.io/collector/receiver v1.65.0 go.opentelemetry.io/collector/receiver/otlpreceiver v0.159.0 - go.opentelemetry.io/otel/metric v1.45.0 - go.opentelemetry.io/otel/trace v1.45.0 + go.opentelemetry.io/otel/metric v1.46.0 + go.opentelemetry.io/otel/trace v1.46.0 go.uber.org/zap v1.28.0 golang.org/x/term v0.45.0 google.golang.org/grpc v1.83.1 @@ -30,14 +32,29 @@ require ( ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/creack/pty v1.1.24 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.1+incompatible // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/foxboron/go-tpm-keyfiles v0.0.0-20251226215517-609e4778396f // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gofrs/flock v0.13.0 // indirect @@ -52,15 +69,34 @@ require ( github.com/knadh/koanf/providers/confmap v1.0.1 // indirect github.com/knadh/koanf/v2 v2.3.6 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/morikuni/aec v1.0.0 // indirect github.com/oapi-codegen/runtime v1.4.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pierrec/lz4/v4 v4.1.28 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/cors v1.11.1 // indirect + github.com/shirou/gopsutil/v4 v4.26.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/collector v0.159.0 // indirect go.opentelemetry.io/collector/client v1.65.0 // indirect @@ -86,7 +122,11 @@ require ( go.opentelemetry.io/collector/receiver/xreceiver v0.159.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect - go.opentelemetry.io/otel v1.45.0 // indirect + go.opentelemetry.io/otel v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/otel/sdk v1.46.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect diff --git a/go.sum b/go.sum index b78d6945..03e409a5 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= @@ -6,15 +12,39 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/dash0hq/dash0-api-client-go v1.21.1 h1:IG14j/kVAcJZGK7MYRb7VhwJwpIoSsY/Dt3Wtof0J9A= -github.com/dash0hq/dash0-api-client-go v1.21.1/go.mod h1:+PufWHDFteVN4eG28EDCjnSQF8vUneO92tnEw39SiRY= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/foxboron/go-tpm-keyfiles v0.0.0-20251226215517-609e4778396f h1:RJ+BDPLSHQO7cSjKBqjPJSbi1qfk9WcsjQDtZiw3dZw= @@ -24,6 +54,9 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= @@ -43,6 +76,8 @@ github.com/google/go-tpm-tools v0.4.7/go.mod h1:gSyXTZHe3fgbzb6WEGd90QucmsnT1SRd github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -64,26 +99,56 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4= github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio= github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -91,6 +156,12 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -98,9 +169,19 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/collector v0.159.0 h1:gOWL2DGKrPSEdDJ++4NfNhx77A409SNrrZW18hE8dKY= @@ -185,16 +266,22 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.7 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= -go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= -go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= -go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= -go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= -go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= -go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= -go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= -go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= -go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= -go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.opentelemetry.io/proto/slim/otlp v1.11.0 h1:zB37f+f99+y6UIZR4h7UpwbXd5kFNyip35U7GaJ/Jik= go.opentelemetry.io/proto/slim/otlp v1.11.0/go.mod h1:mI3DeND+VXZuA4keqFPKDJ3BklwveYm1JqBcEWKDEOM= go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.4.0 h1:mt+DWtks0biKnz0jXMpDbxWN0CHJi6OJDKe4GcREkcs= @@ -218,6 +305,10 @@ golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= @@ -225,8 +316,12 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a h1:97PfJ4tCxY5C7NzzgGqQEMZmXbISdvSArNNEOoUGKBg= +google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a/go.mod h1:1brfde68Npq6+WA75c1EHWPijZEG1kMus61ygPZfn4A= google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= @@ -238,5 +333,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/testutil/fixtures/apply/error-empty-dir-nested/foo/.gitkeep b/internal/testutil/fixtures/apply/error-empty-dir-nested/foo/.gitkeep new file mode 100644 index 00000000..0a14923f --- /dev/null +++ b/internal/testutil/fixtures/apply/error-empty-dir-nested/foo/.gitkeep @@ -0,0 +1,3 @@ +# Placeholder so git tracks this otherwise-empty directory. +# This directory intentionally has no .yaml or .yml files: the apply +# command should report an error when pointed at it. diff --git a/internal/testutil/fixtures/apply/error-empty-dir-nested/foo/readme.txt b/internal/testutil/fixtures/apply/error-empty-dir-nested/foo/readme.txt deleted file mode 100644 index 02f718ca..00000000 --- a/internal/testutil/fixtures/apply/error-empty-dir-nested/foo/readme.txt +++ /dev/null @@ -1,2 +0,0 @@ -This directory has no .yaml or .yml files. -The apply command should report an error. diff --git a/internal/testutil/fixtures/apply/error-empty-dir/.gitkeep b/internal/testutil/fixtures/apply/error-empty-dir/.gitkeep new file mode 100644 index 00000000..0a14923f --- /dev/null +++ b/internal/testutil/fixtures/apply/error-empty-dir/.gitkeep @@ -0,0 +1,3 @@ +# Placeholder so git tracks this otherwise-empty directory. +# This directory intentionally has no .yaml or .yml files: the apply +# command should report an error when pointed at it. diff --git a/internal/testutil/fixtures/apply/error-empty-dir/readme.txt b/internal/testutil/fixtures/apply/error-empty-dir/readme.txt deleted file mode 100644 index 02f718ca..00000000 --- a/internal/testutil/fixtures/apply/error-empty-dir/readme.txt +++ /dev/null @@ -1,2 +0,0 @@ -This directory has no .yaml or .yml files. -The apply command should report an error. diff --git a/internal/testutil/fixtures/git-scenarios/first-push-new-branch.yml b/internal/testutil/fixtures/git-scenarios/first-push-new-branch.yml new file mode 100644 index 00000000..e1384ffb --- /dev/null +++ b/internal/testutil/fixtures/git-scenarios/first-push-new-branch.yml @@ -0,0 +1,22 @@ +kind: GitRepoFixture +spec: + # No interesting history is needed -- the scenario is the literal + # all-zeros SHA GitHub reports for a branch's first push. A minimal real + # repo is still needed, since --since requires -f's target to resolve + # inside one; the ref value itself is what the test actually exercises. + sinceRef: "0000000000000000000000000000000000000000" + repo: + commits: + - message: "add keep.yaml" + changes: + - op: add + name: keep.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: View + metadata: + name: Keep + labels: + dash0.com/id: keep-id + spec: + query: "true" diff --git a/internal/testutil/fixtures/git-scenarios/multi-document-partial-deletion.yml b/internal/testutil/fixtures/git-scenarios/multi-document-partial-deletion.yml new file mode 100644 index 00000000..d269ae16 --- /dev/null +++ b/internal/testutil/fixtures/git-scenarios/multi-document-partial-deletion.yml @@ -0,0 +1,45 @@ +kind: GitRepoFixture +spec: + # A commit with a multi-document YAML file, followed by a commit removing + # one document while the file (and its other document) survives. + sinceRef: before + repo: + commits: + - label: before + message: "add combined dashboard+view" + changes: + - op: add + name: combined.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: Dashboard + metadata: + name: Combined Dashboard + dash0Extensions: + id: dash-combined + spec: + display: + name: Combined Dashboard + --- + apiVersion: dash0.com/v1alpha1 + kind: View + metadata: + name: Combined View + labels: + dash0.com/id: view-combined + spec: + query: "true" + - message: "remove view document, keep dashboard" + changes: + - op: modify + name: combined.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: Dashboard + metadata: + name: Combined Dashboard + dash0Extensions: + id: dash-combined + spec: + display: + name: Combined Dashboard diff --git a/internal/testutil/fixtures/git-scenarios/non-ancestor-force-push.yml b/internal/testutil/fixtures/git-scenarios/non-ancestor-force-push.yml new file mode 100644 index 00000000..5de09d1f --- /dev/null +++ b/internal/testutil/fixtures/git-scenarios/non-ancestor-force-push.yml @@ -0,0 +1,53 @@ +kind: GitRepoFixture +spec: + # A commit "orphaned" is the ref a --since run is pointed at, then history + # is rewritten (resetTo: base + a new commit) to simulate a force-push, + # leaving "orphaned" unreachable from the new HEAD. It stays resolvable by + # SHA (its object is never garbage-collected in this fixture's lifecycle), + # so --since resolves but is not an ancestor of HEAD. + sinceRef: orphaned + repo: + commits: + - label: base + message: "initial commit" + changes: + - op: add + name: keep.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: View + metadata: + name: Keep + labels: + dash0.com/id: keep-id + spec: + query: "true" + - label: orphaned + message: "add to-be-orphaned (this becomes an orphaned commit)" + changes: + - op: add + name: to-be-orphaned.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: Dashboard + metadata: + name: Orphaned + dash0Extensions: + id: orphaned-id + spec: + display: + name: Orphaned + - resetTo: base + message: "rewritten history, does not descend from the orphaned commit" + changes: + - op: add + name: rewritten-history.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: View + metadata: + name: Rewritten + labels: + dash0.com/id: rewritten-id + spec: + query: "true" diff --git a/internal/testutil/fixtures/git-scenarios/prometheus-alert-partial-deletion.yml b/internal/testutil/fixtures/git-scenarios/prometheus-alert-partial-deletion.yml new file mode 100644 index 00000000..f0118dc9 --- /dev/null +++ b/internal/testutil/fixtures/git-scenarios/prometheus-alert-partial-deletion.yml @@ -0,0 +1,47 @@ +kind: GitRepoFixture +spec: + # A PrometheusRule CRD with two alerting rules sharing one dash0.com/id, + # then a commit removing one alert while the CRD (and its shared + # identifier) survives. + sinceRef: before + repo: + commits: + - label: before + message: "add rules with two alerts" + changes: + - op: add + name: rules.yaml + content: | + apiVersion: monitoring.coreos.com/v1 + kind: PrometheusRule + metadata: + name: mixed-rules + labels: + dash0.com/id: shared-rule-id + spec: + groups: + - name: rule-group + interval: 1m + rules: + - alert: HighErrorRate + expr: sum(rate(errors[5m])) > 0.1 + - alert: DiskFull + expr: disk_free_bytes < 1000000 + - message: "remove DiskFull alert" + changes: + - op: modify + name: rules.yaml + content: | + apiVersion: monitoring.coreos.com/v1 + kind: PrometheusRule + metadata: + name: mixed-rules + labels: + dash0.com/id: shared-rule-id + spec: + groups: + - name: rule-group + interval: 1m + rules: + - alert: HighErrorRate + expr: sum(rate(errors[5m])) > 0.1 diff --git a/internal/testutil/fixtures/git-scenarios/prometheus-recording-partial-removal.yml b/internal/testutil/fixtures/git-scenarios/prometheus-recording-partial-removal.yml new file mode 100644 index 00000000..90373c70 --- /dev/null +++ b/internal/testutil/fixtures/git-scenarios/prometheus-recording-partial-removal.yml @@ -0,0 +1,49 @@ +kind: GitRepoFixture +spec: + # A PrometheusRule CRD with one alerting rule and one recording rule + # sharing one dash0.com/id, then a commit removing the recording rule + # while the alert (and the CRD's shared identifier) survives. --since must + # treat this as a plain update, not a deletion: there is no per-record + # identity to diff. + sinceRef: before + repo: + commits: + - label: before + message: "add rules with alert and recording rule" + changes: + - op: add + name: rules.yaml + content: | + apiVersion: monitoring.coreos.com/v1 + kind: PrometheusRule + metadata: + name: mixed-rules + labels: + dash0.com/id: shared-rule-id + spec: + groups: + - name: rule-group + interval: 1m + rules: + - alert: HighErrorRate + expr: sum(rate(errors[5m])) > 0.1 + - record: instance:cpu_usage:avg5m + expr: avg without(cpu) (rate(node_cpu_seconds_total{mode!="idle"}[5m])) + - message: "remove recording rule" + changes: + - op: modify + name: rules.yaml + content: | + apiVersion: monitoring.coreos.com/v1 + kind: PrometheusRule + metadata: + name: mixed-rules + labels: + dash0.com/id: shared-rule-id + spec: + groups: + - name: rule-group + interval: 1m + rules: + - alert: HighErrorRate + expr: sum(rate(errors[5m])) > 0.1 diff --git a/internal/testutil/fixtures/git-scenarios/too-shallow-clone.yml b/internal/testutil/fixtures/git-scenarios/too-shallow-clone.yml new file mode 100644 index 00000000..97bd4165 --- /dev/null +++ b/internal/testutil/fixtures/git-scenarios/too-shallow-clone.yml @@ -0,0 +1,51 @@ +kind: GitRepoFixture +spec: + # The full history (three commits) is preserved in this fixture. The + # shallow clone (`git clone --depth 1 file:// `) is performed + # by the test itself at run time, not baked in here -- a shallow clone's + # whole point is exercising the clone operation itself. --since is pointed + # at the oldest commit, which a --depth 1 clone of HEAD will not have. + sinceRef: old + repo: + commits: + - label: old + message: "add old.yaml" + changes: + - op: add + name: old.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: Dashboard + metadata: + name: Old Dashboard + dash0Extensions: + id: old-id + spec: + display: + name: Old Dashboard + - message: "add middle.yaml" + changes: + - op: add + name: middle.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: View + metadata: + name: Middle View + labels: + dash0.com/id: middle-id + spec: + query: "true" + - message: "add newest.yaml" + changes: + - op: add + name: newest.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: View + metadata: + name: Newest View + labels: + dash0.com/id: newest-id + spec: + query: "true" diff --git a/internal/testutil/fixtures/git-scenarios/whole-file-deletion.yml b/internal/testutil/fixtures/git-scenarios/whole-file-deletion.yml new file mode 100644 index 00000000..e8428412 --- /dev/null +++ b/internal/testutil/fixtures/git-scenarios/whole-file-deletion.yml @@ -0,0 +1,37 @@ +kind: GitRepoFixture +spec: + # A commit with two asset files, followed by a commit removing one file + # entirely -- the other file (and the repo) survives. + sinceRef: before + repo: + commits: + - label: before + message: "add dashboard-a and view-b" + changes: + - op: add + name: dashboard-a.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: Dashboard + metadata: + name: Dashboard A + dash0Extensions: + id: dash-a + spec: + display: + name: Dashboard A + - op: add + name: view-b.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: View + metadata: + name: View B + labels: + dash0.com/id: view-b + spec: + query: "true" + - message: "remove dashboard-a" + changes: + - op: delete + name: dashboard-a.yaml diff --git a/internal/testutil/git_repo_fixture.schema.json b/internal/testutil/git_repo_fixture.schema.json new file mode 100644 index 00000000..cc93666c --- /dev/null +++ b/internal/testutil/git_repo_fixture.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/dash0hq/dash0-cli/internal/testutil/git_repo_fixture.schema.json", + "title": "GitRepoFixture", + "description": "Declarative description of a git repository's commit history, used to build --since test scenario fixtures. See internal/testutil/gitscenario.go and internal/testutil/fixtures/git-scenarios/*.yml.", + "type": "object", + "required": ["kind", "spec"], + "additionalProperties": false, + "properties": { + "kind": { + "const": "GitRepoFixture" + }, + "spec": { + "type": "object", + "required": ["sinceRef", "repo"], + "additionalProperties": false, + "properties": { + "sinceRef": { + "type": "string", + "minLength": 1, + "description": "The --since value this scenario is designed to be tested with: either the label of one of repo.commits, or a literal ref value (e.g. git's all-zeros sentinel) used as-is when it doesn't match any commit's label." + }, + "repo": { + "type": "object", + "required": ["commits"], + "additionalProperties": false, + "properties": { + "commits": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/commit" } + } + } + } + } + } + }, + "$defs": { + "commit": { + "type": "object", + "required": ["message"], + "additionalProperties": false, + "properties": { + "label": { + "type": "string", + "minLength": 1, + "description": "Names this commit so sinceRef or a later commit's resetTo can refer back to it." + }, + "resetTo": { + "type": "string", + "minLength": 1, + "description": "Hard-resets the working tree to the labeled commit before applying this commit's changes -- simulating a force-push/history rewrite." + }, + "message": { + "type": "string", + "minLength": 1 + }, + "changes": { + "type": "array", + "items": { "$ref": "#/$defs/change" } + } + } + }, + "change": { + "type": "object", + "required": ["op", "name"], + "additionalProperties": false, + "properties": { + "op": { + "enum": ["add", "modify", "delete"], + "description": "What this change does to the named file. Explicit rather than inferred, so a commit's intent (add vs. modify vs. delete) is readable without cross-referencing earlier commits, and so an inconsistent fixture (e.g. 'add' on a file that already exists) can be caught at build time." + }, + "name": { + "type": "string", + "minLength": 1 + }, + "content": { + "type": "string", + "description": "The file's new full content. Required for 'add'/'modify'; must be absent for 'delete'." + } + }, + "if": { + "properties": { "op": { "const": "delete" } } + }, + "then": { + "not": { "required": ["content"] } + }, + "else": { + "required": ["content"] + } + } + } +} diff --git a/internal/testutil/gitscenario.go b/internal/testutil/gitscenario.go new file mode 100644 index 00000000..0c223ca5 --- /dev/null +++ b/internal/testutil/gitscenario.go @@ -0,0 +1,181 @@ +package testutil + +import ( + "maps" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// GitScenariosDir returns the absolute path to the git-scenarios fixtures +// directory. +func GitScenariosDir() string { + return filepath.Join(FixturesDir(), "git-scenarios") +} + +// GitRepoFixture declaratively describes a git repository's history for a +// `--since` test scenario. See internal/testutil/fixtures/git-scenarios/ +// for examples, and BuildGitScenario for how one is turned into a real repo. +type GitRepoFixture struct { + Kind string `yaml:"kind"` + Spec GitRepoFixtureSpec `yaml:"spec"` +} + +// GitRepoFixtureSpec is a GitRepoFixture's body. +type GitRepoFixtureSpec struct { + // SinceRef is the --since value this scenario is designed to be tested + // with: either the Label of one of Repo.Commits, or a literal ref value + // (e.g. git's all-zeros sentinel for a "first push" scenario) used + // as-is when it doesn't match any commit's label. + SinceRef string `yaml:"sinceRef"` + Repo GitRepo `yaml:"repo"` +} + +// GitRepo is an ordered list of commits to apply to a fresh repository. +type GitRepo struct { + Commits []GitCommit `yaml:"commits"` +} + +// GitCommit describes one commit: an ordered list of file changes applied +// to the working tree, then committed. +type GitCommit struct { + // Label optionally names this commit so SinceRef or a later commit's + // ResetTo can refer back to it. + Label string `yaml:"label,omitempty"` + // ResetTo, if set, hard-resets the working tree to the labeled commit + // before applying this commit's Changes -- simulating a force-push/ + // history rewrite that leaves earlier commits orphaned (still + // resolvable by SHA, but no longer an ancestor of HEAD). + ResetTo string `yaml:"resetTo,omitempty"` + Message string `yaml:"message"` + Changes []GitChange `yaml:"changes,omitempty"` +} + +// GitChangeOp names what a GitChange does to a file. +type GitChangeOp string + +const ( + GitChangeAdd GitChangeOp = "add" + GitChangeModify GitChangeOp = "modify" + GitChangeDelete GitChangeOp = "delete" +) + +// GitChange is one file-level change within a commit. Op is explicit rather +// than inferred (e.g. from the same Name reappearing with different Content +// in a later commit) so a fixture's history reads correctly on its own, +// without cross-referencing earlier commits, and so BuildGitScenario can +// catch an inconsistent fixture (e.g. "add" on a file that already exists) +// at build time. +type GitChange struct { + Op GitChangeOp `yaml:"op"` + Name string `yaml:"name"` + // Content is the file's new full content. Required for "add" and + // "modify"; must be empty for "delete". + Content string `yaml:"content,omitempty"` +} + +// BuildGitScenario reads the named git-scenario fixture +// (internal/testutil/fixtures/git-scenarios/.yml) and replays its +// commit history into a fresh repository under t.TempDir(). It returns the +// repository directory and the ref the scenario wants tests to pass as +// --since. +// +// This is the one thing every test tier (unit, integration, e2e) calls — no +// tier re-derives or hand-rolls its own git repo setup, so all three build +// the exact same repo history per scenario. Building the repo fresh from a +// declarative description (rather than unpacking a checked-in binary +// artifact) keeps each scenario's history readable and diffable in review, +// with nothing to regenerate when a scenario's shape changes. +func BuildGitScenario(t *testing.T, name string) (repoDir, ref string) { + t.Helper() + + fixturePath := filepath.Join(GitScenariosDir(), name+".yml") + data, err := os.ReadFile(fixturePath) + require.NoErrorf(t, err, "failed to read git scenario fixture %s", fixturePath) + + var fixture GitRepoFixture + require.NoErrorf(t, yaml.Unmarshal(data, &fixture), "failed to parse git scenario fixture %s", fixturePath) + require.Equalf(t, "GitRepoFixture", fixture.Kind, "%s: unexpected kind %q", fixturePath, fixture.Kind) + + repoDir = filepath.Join(t.TempDir(), "repo") + require.NoError(t, os.MkdirAll(repoDir, 0o755)) + + runGit(t, repoDir, "init", "-q", "-b", "main") + runGit(t, repoDir, "config", "user.email", "fixtures@dash0.example") + runGit(t, repoDir, "config", "user.name", "Dash0 Fixture") + // This is the scenario's own throwaway repo (under t.TempDir()), not the + // developer's real repo or its global git config. + runGit(t, repoDir, "config", "commit.gpgsign", "false") + + labels := map[string]string{} + // existingFiles tracks which file names are present in the working tree + // as commits are replayed, so an "add"/"modify"/"delete" that doesn't + // match reality (e.g. "add" on a file that's already there) fails loudly + // here rather than silently doing the wrong thing. fileSets snapshots + // this set at each labeled commit, so a later ResetTo restores the set + // as it stood then, matching the real `git reset --hard`. + existingFiles := map[string]bool{} + fileSets := map[string]map[string]bool{} + + for _, commit := range fixture.Spec.Repo.Commits { + if commit.ResetTo != "" { + sha, ok := labels[commit.ResetTo] + require.Truef(t, ok, "%s: commit %q resets to unknown label %q", fixturePath, commit.Message, commit.ResetTo) + runGit(t, repoDir, "reset", "-q", "--hard", sha) + existingFiles = cloneFileSet(fileSets[commit.ResetTo]) + } + + for _, change := range commit.Changes { + target := filepath.Join(repoDir, change.Name) + switch change.Op { + case GitChangeAdd: + require.Falsef(t, existingFiles[change.Name], "%s: commit %q: op %q on %q, which already exists (use %q?)", fixturePath, commit.Message, change.Op, change.Name, GitChangeModify) + require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755)) + require.NoError(t, os.WriteFile(target, []byte(change.Content), 0o644)) + existingFiles[change.Name] = true + case GitChangeModify: + require.Truef(t, existingFiles[change.Name], "%s: commit %q: op %q on %q, which does not exist yet (use %q?)", fixturePath, commit.Message, change.Op, change.Name, GitChangeAdd) + require.NoError(t, os.WriteFile(target, []byte(change.Content), 0o644)) + case GitChangeDelete: + require.Truef(t, existingFiles[change.Name], "%s: commit %q: op %q on %q, which does not exist", fixturePath, commit.Message, change.Op, change.Name) + require.NoError(t, os.Remove(target)) + delete(existingFiles, change.Name) + default: + t.Fatalf("%s: commit %q: unknown op %q for %q", fixturePath, commit.Message, change.Op, change.Name) + } + } + + runGit(t, repoDir, "add", "-A") + runGit(t, repoDir, "commit", "-q", "-m", commit.Message) + + if commit.Label != "" { + labels[commit.Label] = runGit(t, repoDir, "rev-parse", "HEAD") + fileSets[commit.Label] = cloneFileSet(existingFiles) + } + } + + ref = fixture.Spec.SinceRef + if sha, ok := labels[ref]; ok { + ref = sha + } + return repoDir, ref +} + +func runGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %v failed: %s", args, out) + return strings.TrimSpace(string(out)) +} + +func cloneFileSet(set map[string]bool) map[string]bool { + clone := make(map[string]bool, len(set)) + maps.Copy(clone, set) + return clone +} diff --git a/internal/testutil/gitscenario_test.go b/internal/testutil/gitscenario_test.go new file mode 100644 index 00000000..e717dff4 --- /dev/null +++ b/internal/testutil/gitscenario_test.go @@ -0,0 +1,163 @@ +package testutil + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + sigsyaml "sigs.k8s.io/yaml" +) + +func runGitScenario(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %v failed: %s", args, out) + return strings.TrimSpace(string(out)) +} + +// compileGitRepoFixtureSchema compiles internal/testutil/git_repo_fixture.schema.json. +func compileGitRepoFixtureSchema(t *testing.T) *jsonschema.Schema { + t.Helper() + + const schemaPath = "git_repo_fixture.schema.json" + data, err := os.ReadFile(schemaPath) + require.NoErrorf(t, err, "failed to read %s", schemaPath) + + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + require.NoErrorf(t, err, "failed to parse %s", schemaPath) + + compiler := jsonschema.NewCompiler() + require.NoError(t, compiler.AddResource(schemaPath, doc)) + + schema, err := compiler.Compile(schemaPath) + require.NoErrorf(t, err, "failed to compile %s", schemaPath) + return schema +} + +// TestGitScenarioFixtures_MatchSchema validates every checked-in git-scenario +// fixture against git_repo_fixture.schema.json, so a malformed fixture (a +// typo'd field name, a missing required key) fails loudly here instead of +// surfacing as a confusing zero-value somewhere inside BuildGitScenario. +func TestGitScenarioFixtures_MatchSchema(t *testing.T) { + schema := compileGitRepoFixtureSchema(t) + + entries, err := os.ReadDir(GitScenariosDir()) + require.NoError(t, err) + + var fixtureCount int + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".yml" { + continue + } + fixtureCount++ + + t.Run(entry.Name(), func(t *testing.T) { + fixturePath := filepath.Join(GitScenariosDir(), entry.Name()) + yamlData, err := os.ReadFile(fixturePath) + require.NoError(t, err) + + jsonData, err := sigsyaml.YAMLToJSON(yamlData) + require.NoErrorf(t, err, "failed to convert %s to JSON", fixturePath) + + instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(jsonData)) + require.NoError(t, err) + + if err := schema.Validate(instance); err != nil { + t.Errorf("%s does not match the GitRepoFixture schema:\n%v", fixturePath, err) + } + }) + } + require.NotZero(t, fixtureCount, "expected at least one .yml fixture in %s", GitScenariosDir()) +} + +func TestBuildGitScenario_WholeFileDeletion(t *testing.T) { + repoDir, ref := BuildGitScenario(t, "whole-file-deletion") + + assert.Len(t, ref, 40, "ref should be a full commit SHA") + assert.NoFileExists(t, filepath.Join(repoDir, "dashboard-a.yaml"), "the deleted file must not exist at HEAD") + assert.FileExists(t, filepath.Join(repoDir, "view-b.yaml")) + + atRef := runGitScenario(t, repoDir, "cat-file", "-p", ref+":dashboard-a.yaml") + assert.Contains(t, atRef, "dash-a") +} + +func TestBuildGitScenario_MultiDocumentPartialDeletion(t *testing.T) { + repoDir, ref := BuildGitScenario(t, "multi-document-partial-deletion") + + current, err := os.ReadFile(filepath.Join(repoDir, "combined.yaml")) + require.NoError(t, err) + assert.NotContains(t, string(current), "view-combined", "the view document must be gone from the current file") + assert.Contains(t, string(current), "dash-combined") + + atRef := runGitScenario(t, repoDir, "cat-file", "-p", ref+":combined.yaml") + assert.Contains(t, atRef, "view-combined", "the view document must still be present at ref") + assert.Contains(t, atRef, "dash-combined") +} + +func TestBuildGitScenario_PrometheusAlertPartialDeletion(t *testing.T) { + repoDir, ref := BuildGitScenario(t, "prometheus-alert-partial-deletion") + + current, err := os.ReadFile(filepath.Join(repoDir, "rules.yaml")) + require.NoError(t, err) + assert.NotContains(t, string(current), "DiskFull") + assert.Contains(t, string(current), "HighErrorRate") + assert.Contains(t, string(current), "shared-rule-id") + + atRef := runGitScenario(t, repoDir, "cat-file", "-p", ref+":rules.yaml") + assert.Contains(t, atRef, "DiskFull") + assert.Contains(t, atRef, "HighErrorRate") +} + +func TestBuildGitScenario_PrometheusRecordingPartialRemoval(t *testing.T) { + repoDir, ref := BuildGitScenario(t, "prometheus-recording-partial-removal") + + current, err := os.ReadFile(filepath.Join(repoDir, "rules.yaml")) + require.NoError(t, err) + assert.NotContains(t, string(current), "instance:cpu_usage:avg5m") + assert.Contains(t, string(current), "HighErrorRate") + + atRef := runGitScenario(t, repoDir, "cat-file", "-p", ref+":rules.yaml") + assert.Contains(t, atRef, "instance:cpu_usage:avg5m") +} + +func TestBuildGitScenario_FirstPushNewBranch(t *testing.T) { + repoDir, ref := BuildGitScenario(t, "first-push-new-branch") + + assert.Equal(t, "0000000000000000000000000000000000000000", ref) + assert.FileExists(t, filepath.Join(repoDir, "keep.yaml")) +} + +func TestBuildGitScenario_NonAncestorForcePush(t *testing.T) { + repoDir, ref := BuildGitScenario(t, "non-ancestor-force-push") + + // ref must resolve... + sha := runGitScenario(t, repoDir, "rev-parse", "--verify", ref+"^{commit}") + assert.Equal(t, ref, sha) + + // ...but must not be an ancestor of HEAD (git merge-base --is-ancestor + // exits 1 for "not an ancestor", which Run() surfaces as a non-nil err). + cmd := exec.Command("git", "-C", repoDir, "merge-base", "--is-ancestor", ref, "HEAD") + err := cmd.Run() + require.Error(t, err, "the orphaned commit must not be an ancestor of the rewritten HEAD") +} + +func TestBuildGitScenario_TooShallowClone(t *testing.T) { + repoDir, ref := BuildGitScenario(t, "too-shallow-clone") + + // The checked-in fixture carries full history: ref must resolve directly. + runGitScenario(t, repoDir, "rev-parse", "--verify", ref+"^{commit}") + + shallowDir := t.TempDir() + "/shallow" + runGitScenario(t, ".", "clone", "-q", "--depth", "1", "file://"+repoDir, shallowDir) + + cmd := exec.Command("git", "-C", shallowDir, "rev-parse", "--verify", ref+"^{commit}") + err := cmd.Run() + require.Error(t, err, "a --depth 1 clone must not have the older ref commit") +} diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile new file mode 100644 index 00000000..b84fbc3d --- /dev/null +++ b/test/e2e/Dockerfile @@ -0,0 +1,12 @@ +# Minimal test-only image for the `--since` end-to-end harness (never +# shipped). Expects the linux/ `dash0` binary to already be +# cross-compiled by the host into this directory before `docker build` runs +# -- see the `test-e2e-image` Makefile target -- since the harness's +# go.mod replace directive (a local sibling checkout of dash0-api-client-go) +# would otherwise need to be part of the Docker build context too. +FROM alpine:3.20 +RUN apk add --no-cache git +COPY dash0 /usr/local/bin/dash0 +RUN chmod +x /usr/local/bin/dash0 +RUN mkdir -p /work +ENTRYPOINT ["tail", "-f", "/dev/null"] diff --git a/test/e2e/setup_test.go b/test/e2e/setup_test.go new file mode 100644 index 00000000..13e6dd8d --- /dev/null +++ b/test/e2e/setup_test.go @@ -0,0 +1,96 @@ +//go:build e2e + +// Package e2e exercises the real `dash0` binary against a real `git` binary +// inside a container, closing the gap unit tests (in-process) and +// integration tests (real temp git repo, but still in-process against a +// mocked HTTP server) can't: `--since` shells out to `git` rather than using +// a Go git library, so this is the only tier that proves that process +// boundary actually works. +// +// Scoped to `dash0 apply --since` for now; `dash0 diff --since` coverage +// will be added once that command exists. +package e2e + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "testing" + + "github.com/testcontainers/testcontainers-go" +) + +const dash0ImageTag = "dash0-cli-e2e-test:latest" + +var buildImageOnce sync.Once +var buildImageErr error + +// buildE2EImage cross-compiles a linux binary for the host's container +// architecture and builds the e2e test image from it, once per test binary +// run. The go build step runs on the host (not inside Docker) specifically +// so it can resolve this module's go.mod replace directive (a local sibling +// checkout of dash0-api-client-go) without needing that directory inside +// the Docker build context too. +func buildE2EImage(t *testing.T) { + t.Helper() + buildImageOnce.Do(func() { + buildImageErr = doBuildE2EImage() + }) + if buildImageErr != nil { + t.Fatalf("failed to build e2e image: %v", buildImageErr) + } +} + +func doBuildE2EImage() error { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + return fmt.Errorf("failed to determine test/e2e directory") + } + e2eDir := filepath.Dir(thisFile) + repoRoot := filepath.Dir(filepath.Dir(e2eDir)) + binaryPath := filepath.Join(e2eDir, "dash0") + + buildCmd := exec.Command("go", "build", "-o", binaryPath, "./cmd/dash0") + buildCmd.Dir = repoRoot + buildCmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH="+runtime.GOARCH) + if out, err := buildCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to cross-compile dash0 for linux/%s: %w\n%s", runtime.GOARCH, err, out) + } + defer os.Remove(binaryPath) + + dockerCmd := exec.Command("docker", "build", "-t", dash0ImageTag, e2eDir) + if out, err := dockerCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to build e2e Docker image: %w\n%s", err, out) + } + return nil +} + +// startContainer starts a container from the pre-built e2e image, with +// hostPort (the mock Dash0 API server's port, on the host) reachable from +// inside the container at gitutilHostInternal:hostPort. +func startContainer(ctx context.Context, t *testing.T, hostPort int) testcontainers.Container { + t.Helper() + buildE2EImage(t) + + req := testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: dash0ImageTag, + HostAccessPorts: []int{hostPort}, + }, + Started: true, + } + container, err := testcontainers.GenericContainer(ctx, req) + if err != nil { + t.Fatalf("failed to start e2e container: %v", err) + } + t.Cleanup(func() { + if err := container.Terminate(context.Background()); err != nil { + t.Logf("failed to terminate e2e container: %v", err) + } + }) + return container +} diff --git a/test/e2e/since_e2e_test.go b/test/e2e/since_e2e_test.go new file mode 100644 index 00000000..08c0e224 --- /dev/null +++ b/test/e2e/since_e2e_test.go @@ -0,0 +1,286 @@ +//go:build e2e + +package e2e + +import ( + "context" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/dash0hq/dash0-cli/internal/testutil" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/exec" +) + +const ( + testAuthToken = "auth_test_token" + apiPathCheckRules = "/api/alerting/check-rules" +) + +var ( + dashboardIDPattern = regexp.MustCompile(`^/api/dashboards/[^/]+$`) + checkRuleIDPattern = regexp.MustCompile(`^/api/alerting/check-rules/[^/]+$`) + viewIDPattern = regexp.MustCompile(`^/api/views/[^/]+$`) +) + +// drainExecOutput reads an exec result reader to completion. testcontainers' +// Exec streams stdout+stderr multiplexed on one reader; a short read isn't +// meaningful here since these are short-lived, non-interactive CLI runs. +func drainExecOutput(reader io.Reader) string { + if reader == nil { + return "" + } + data, _ := io.ReadAll(reader) + return string(data) +} + +// execDash0 runs `dash0 ` inside container, targeting the mock API +// server at http://:, and returns its exit code and +// combined output. +func execDash0(ctx context.Context, t *testing.T, container testcontainers.Container, hostPort int, args ...string) (int, string) { + t.Helper() + + apiURL := "http://" + testcontainers.HostInternal + ":" + strconv.Itoa(hostPort) + fullArgs := append([]string{"dash0"}, args...) + fullArgs = append(fullArgs, "--api-url", apiURL, "--auth-token", testAuthToken) + + exitCode, reader, err := container.Exec(ctx, fullArgs, exec.WithEnv([]string{"DASH0_CONFIG_DIR=/tmp/dash0-config"})) + if err != nil { + t.Fatalf("failed to exec %v: %v", fullArgs, err) + } + return exitCode, drainExecOutput(reader) +} + +func mockServerPort(t *testing.T, server *testutil.MockServer) int { + t.Helper() + u, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse mock server URL %q: %v", server.URL, err) + } + port, err := strconv.Atoi(u.Port()) + if err != nil { + t.Fatalf("failed to parse mock server port from %q: %v", server.URL, err) + } + return port +} + +func copyScenarioIntoContainer(ctx context.Context, t *testing.T, container testcontainers.Container, repoDir string) { + t.Helper() + if err := container.CopyDirToContainer(ctx, repoDir, "/work/repo", 0o755); err != nil { + t.Fatalf("failed to copy scenario repo into container: %v", err) + } + + // docker cp preserves the host file owner's UID (the user running this + // test), which the container's root user doesn't match -- tripping + // git's post-CVE-2022-24765 "dubious ownership" guard, for both + // /work/repo itself and, separately, /work/repo/.git when it's later + // used as a local clone source (too-shallow-clone). A real CI + // environment hits this same mismatch whenever a checkout is owned by a + // different UID than the one running commands; actions/checkout works + // around it by marking the checkout safe (with the same '*' wildcard), + // which is what we replicate here rather than disabling the protection + // inside dash0 itself. + exitCode, output := execCommand(ctx, t, container, "git", "config", "--global", "--add", "safe.directory", "*") + if exitCode != 0 { + t.Fatalf("failed to mark /work/repo safe: %s", output) + } +} + +func TestE2E_ApplySince_WholeFileDeletion(t *testing.T) { + ctx := context.Background() + repoDir, ref := testutil.BuildGitScenario(t, "whole-file-deletion") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{StatusCode: http.StatusNotFound, Body: map[string]any{}}) + server.OnPattern(http.MethodPut, viewIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, BodyFile: testutil.FixtureViewsImportSuccess}) + server.OnPattern(http.MethodDelete, dashboardIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, Body: map[string]any{}}) + + container := startContainer(ctx, t, mockServerPort(t, server)) + copyScenarioIntoContainer(ctx, t, container, repoDir) + + exitCode, output := execDash0(ctx, t, container, mockServerPort(t, server), + "--experimental", "apply", "-f", "/work/repo", "--since", ref, "--force") + + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d. Output:\n%s", exitCode, output) + } + if !strings.Contains(output, "deleted") { + t.Errorf("expected output to mention a deletion, got:\n%s", output) + } +} + +func TestE2E_ApplySince_MultiDocumentPartialDeletion(t *testing.T) { + ctx := context.Background() + repoDir, ref := testutil.BuildGitScenario(t, "multi-document-partial-deletion") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, dashboardIDPattern, testutil.MockResponse{StatusCode: http.StatusNotFound, Body: map[string]any{}}) + server.OnPattern(http.MethodPut, dashboardIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, BodyFile: testutil.FixtureDashboardsImportSuccess}) + server.OnPattern(http.MethodDelete, viewIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, Body: map[string]any{}}) + + container := startContainer(ctx, t, mockServerPort(t, server)) + copyScenarioIntoContainer(ctx, t, container, repoDir) + + exitCode, output := execDash0(ctx, t, container, mockServerPort(t, server), + "--experimental", "apply", "-f", "/work/repo", "--since", ref, "--force") + + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d. Output:\n%s", exitCode, output) + } + if !strings.Contains(output, "deleted") { + t.Errorf("expected output to mention a deletion, got:\n%s", output) + } +} + +func TestE2E_ApplySince_PrometheusAlertPartialDeletion(t *testing.T) { + ctx := context.Background() + repoDir, ref := testutil.BuildGitScenario(t, "prometheus-alert-partial-deletion") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, checkRuleIDPattern, testutil.MockResponse{StatusCode: http.StatusNotFound, Body: map[string]any{}}) + server.OnPattern(http.MethodPut, checkRuleIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, BodyFile: testutil.FixtureCheckRulesImportSuccess}) + server.On(http.MethodGet, apiPathCheckRules, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: []map[string]any{ + {"dataset": "default", "id": "disk-full-check-rule-id", "name": "rule-group - DiskFull"}, + }, + }) + server.OnPattern(http.MethodDelete, checkRuleIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, Body: map[string]any{}}) + + container := startContainer(ctx, t, mockServerPort(t, server)) + copyScenarioIntoContainer(ctx, t, container, repoDir) + + exitCode, output := execDash0(ctx, t, container, mockServerPort(t, server), + "--experimental", "apply", "-f", "/work/repo", "--since", ref, "--force") + + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d. Output:\n%s", exitCode, output) + } + if !strings.Contains(output, "DiskFull") { + t.Errorf("expected output to mention the removed alert's check rule, got:\n%s", output) + } +} + +func TestE2E_ApplySince_PrometheusRecordingPartialRemovalIsNotADeletion(t *testing.T) { + ctx := context.Background() + repoDir, ref := testutil.BuildGitScenario(t, "prometheus-recording-partial-removal") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, checkRuleIDPattern, testutil.MockResponse{StatusCode: http.StatusNotFound, Body: map[string]any{}}) + server.OnPattern(http.MethodPut, checkRuleIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, BodyFile: testutil.FixtureCheckRulesImportSuccess}) + // Deliberately no recording-rules route: hitting one would 404 through + // the mock server's default handler, which the exit-code check below + // would surface as a failure. + + container := startContainer(ctx, t, mockServerPort(t, server)) + copyScenarioIntoContainer(ctx, t, container, repoDir) + + exitCode, output := execDash0(ctx, t, container, mockServerPort(t, server), + "--experimental", "apply", "-f", "/work/repo", "--since", ref, "--force") + + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d. Output:\n%s", exitCode, output) + } + if strings.Contains(output, "recording") { + t.Errorf("removing a record entry from a surviving CRD must not be treated as a deletion, got:\n%s", output) + } +} + +func TestE2E_ApplySince_FirstPushNewBranch(t *testing.T) { + ctx := context.Background() + repoDir, ref := testutil.BuildGitScenario(t, "first-push-new-branch") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + // No routes registered: the all-zeros sentinel must fail before any API call. + + container := startContainer(ctx, t, mockServerPort(t, server)) + copyScenarioIntoContainer(ctx, t, container, repoDir) + + exitCode, output := execDash0(ctx, t, container, mockServerPort(t, server), + "--experimental", "apply", "-f", "/work/repo", "--since", ref, "--force") + + if exitCode == 0 { + t.Fatalf("expected a non-zero exit for the all-zeros sentinel, got 0. Output:\n%s", output) + } + if !strings.Contains(output, "all-zeros") { + t.Errorf("expected the all-zeros error message, got:\n%s", output) + } +} + +func TestE2E_ApplySince_NonAncestorForcePush(t *testing.T) { + ctx := context.Background() + repoDir, ref := testutil.BuildGitScenario(t, "non-ancestor-force-push") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{StatusCode: http.StatusNotFound, Body: map[string]any{}}) + server.OnPattern(http.MethodPut, viewIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, BodyFile: testutil.FixtureViewsImportSuccess}) + server.OnPattern(http.MethodDelete, dashboardIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, Body: map[string]any{}}) + + container := startContainer(ctx, t, mockServerPort(t, server)) + copyScenarioIntoContainer(ctx, t, container, repoDir) + + t.Run("no --force, no terminal: hard fails", func(t *testing.T) { + exitCode, output := execDash0(ctx, t, container, mockServerPort(t, server), + "--experimental", "apply", "-f", "/work/repo", "--since", ref) + if exitCode == 0 { + t.Fatalf("expected a non-zero exit with no terminal to confirm against, got 0. Output:\n%s", output) + } + if !strings.Contains(output, "ancestor") { + t.Errorf("expected a non-ancestor-related error, got:\n%s", output) + } + }) + + t.Run("--force bypasses the confirmation", func(t *testing.T) { + exitCode, output := execDash0(ctx, t, container, mockServerPort(t, server), + "--experimental", "apply", "-f", "/work/repo", "--since", ref, "--force") + if exitCode != 0 { + t.Fatalf("expected exit 0 with --force, got %d. Output:\n%s", exitCode, output) + } + if !strings.Contains(output, "not an ancestor") { + t.Errorf("expected the non-ancestor warning to still be printed, got:\n%s", output) + } + }) +} + +func TestE2E_ApplySince_TooShallowClone(t *testing.T) { + ctx := context.Background() + repoDir, ref := testutil.BuildGitScenario(t, "too-shallow-clone") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + // No routes registered: an unresolvable ref must fail before any API call. + + container := startContainer(ctx, t, mockServerPort(t, server)) + copyScenarioIntoContainer(ctx, t, container, repoDir) + + // Perform the shallow clone inside the container, against the just-copied + // full-history repo -- this is the one step the checked-in fixture + // deliberately does not bake in (see generate_git_scenarios.sh). + exitCode, output := execCommand(ctx, t, container, "git", "clone", "-q", "--depth", "1", "file:///work/repo", "/work/shallow") + if exitCode != 0 { + t.Fatalf("failed to create the shallow clone: %d\n%s", exitCode, output) + } + + exitCode, output = execDash0(ctx, t, container, mockServerPort(t, server), + "--experimental", "apply", "-f", "/work/shallow", "--since", ref, "--force") + + if exitCode == 0 { + t.Fatalf("expected a non-zero exit for an unresolvable (too-shallow) ref, got 0. Output:\n%s", output) + } + if !strings.Contains(output, "could not be resolved") { + t.Errorf("expected the unresolvable-ref error message, got:\n%s", output) + } +} + +func execCommand(ctx context.Context, t *testing.T, container testcontainers.Container, args ...string) (int, string) { + t.Helper() + exitCode, reader, err := container.Exec(ctx, args) + if err != nil { + t.Fatalf("failed to exec %v: %v", args, err) + } + return exitCode, drainExecOutput(reader) +} From 746717e086a8d47917b432e920be8e7d3c17b1fd Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 17 Aug 2026 14:59:59 +0200 Subject: [PATCH 06/42] docs(apply): track --since follow-ups surfaced by doc review Adds four checklist items for gaps a ce-doc-review pass found in the --since implementation: releasing the pending dash0-api-client-go dependency and dropping the go.mod replace directive (1.9), the check-rule name-collision risk in the alerting-rule deletion lookup (4.16), hardening the ID-only spam-filter deletion path to fail instead of warn (4.17), and documenting dash0 diff's exit-code CI-consumption pattern once that command exists (8.8). Tracking only; no code changes. --- openspec/changes/add-diff-and-since-flag/tasks.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openspec/changes/add-diff-and-since-flag/tasks.md b/openspec/changes/add-diff-and-since-flag/tasks.md index cca3911a..6925ccd9 100644 --- a/openspec/changes/add-diff-and-since-flag/tasks.md +++ b/openspec/changes/add-diff-and-since-flag/tasks.md @@ -8,6 +8,7 @@ - [x] 1.6 Alerting-rule partial removal (`Diff`'s `PrometheusAlertsByIdentifier` comparison): for a `PrometheusRule` CRD that still exists (its CRD-level identifier is unchanged), diffs the `(group.name, alert.name)` list between `` and current disk contents to detect an individual alert that disappeared while others remain, without conflating it with a whole-CRD deletion. - [x] 1.7 No-identifier deletion candidate (`Diff`'s `NoIdentifier` handling, keyed by the underlying file path via `NoIdentifierDoc.FilePath` rather than the multi-document-suffixed doc path): if a document at `` corresponding to a deletion candidate has no `dash0.com/id`/origin at all, and its file no longer exists on disk, this is surfaced distinctly (caller fails the whole run — see 4.7). - [x] 1.8 Unit tests for all of the above (`plumbing_test.go`, `ref_test.go`, `snapshot_test.go`, `diff_test.go`) — ref classification, identifier-set diffing, multi-document YAML, PrometheusRule CRD-level vs per-alert-name diffing, the no-identifier file-path-vs-doc-path regression case, and identifier-survives-under-a-different-path (rename) producing no diff. **Built on ad hoc real git repos created inline (`testrepo_test.go`) rather than Section 2's fixtures**, since Section 2 (declarative YAML scenario fixtures) did not exist yet at the time — migrate these tests to `testutil.BuildGitScenario` per 2.11 (optional cleanup, not blocking). +- [ ] 1.9 Track releasing the pending `dash0-api-client-go` change (`yaml/identifier.go`'s `ExtractIdentifier`/`ExtractPrometheusAlertNames`, currently only in a local, uncommitted sibling checkout — see 1.3), cutting a tagged release, and bumping `dash0-cli`'s `go.mod` to it, removing the `replace` directive. Section 9's `make build`/`make test`/`make test-e2e` verification items are blocked on this landing first — the entire CI pipeline fails until it's done (see 6.6). ## 2. Shared git-repo scenario fixtures @@ -51,6 +52,8 @@ Both `diff` (Section 5) and `apply --since` (Section 4) are gated behind `--expe - [x] 4.13 Unit tests (`internal/apply/since_test.go`): `--since` experimental-gate check exercised through `apply` itself, stdin-rejection, ref-classification error messages, `computeDeletionPlan`'s non-ancestor-ref contract (returns a warning, never prompts — see the 4.5 deviation note for where the confirmation flow itself is now tested), no-identifier hard-fail, successful whole-file plan computation, `--since ""` (explicitly empty) hitting the `RefEmpty` error through the CLI entry point. - [x] 4.14 Integration tests (`internal/apply/since_integration_test.go`, mock server + ad hoc git repos — Section 2's fixtures don't exist yet, see 2.11): whole-file deletion, multi-document YAML partial deletion, PrometheusRule alerting-rule partial deletion (name-based), PrometheusRule recording-rule partial removal (asserted as *not* a deletion — no call to the recording-rules endpoint at all), unresolvable ref, declined-deletion non-zero exit, `--dry-run --since` preview making no API call — all run with `--experimental` set. - [ ] 4.15 Roundtrip tests — see Section 7. End-to-end (real binary + real `git`) tests — see Section 6. +- [ ] 4.16 `findCheckRuleIDByName` (4.7) currently returns the first check rule whose composed name matches, with no uniqueness guard — two CRDs (or two groups with the same name) producing the same ` - ` string can cause `--since` to delete the wrong live check rule. Change it to fail loudly on an ambiguous (multiple-match) lookup instead of silently picking the first result, and add a regression test covering the name-collision case. +- [ ] 4.17 Change the ID-only `Dash0SpamFilter` deletion path (4.6's second deviation) from a printed warning to a hard failure, mirroring 4.8's no-identifier rule: `design.md` already rejects a scrollable warning as an acceptable mitigation for this failure class (the id `--since` looks up may not match the reassigned live id), so the same reasoning applies here rather than being treated as an exception. ## 5. New `dash0 diff` command @@ -104,6 +107,7 @@ Everything above tests either in-process (unit tests share the Go test binary; i - [ ] 8.5 Document the correct, safe GitHub Actions `--since` invocation pattern (quoting, `if:` gating, and the current `-X` requirement) for users not using `asset-synch` — cross-reference `openspec/changes/add-asset-synch-action` for the convenience-action alternative. - [ ] 8.6 Changelog entries (`make chlog-new`) for: the new `diff` command, `apply --since`/`--force`, and the `apply --dry-run` deprecation — all noting the `-X` requirement where applicable. - [ ] 8.7 Extend `docs/promoting-commands-to-stable.md` with the flag-level promotion case (this doc currently only covers whole-command promotion): removing a `RequireExperimentalFlag` call instead of `RequireExperimental`, dropping `-X` from just the affected flag's examples rather than the whole command's, and the equivalent backward-compat test shape (7.7). This is a project-convention update, not just documentation for this one feature — the next feature needing a flag-level gate will look here. +- [ ] 8.8 Document the recommended CI invocation pattern for `dash0 diff`'s exit code (`0` clean / `1` differences pending / `2` error) — e.g. explicit branching on the exit code or `continue-on-error`-style guidance — so a naive CI step doesn't fail on the routine "changes pending" case, the same footgun `kubectl diff` users commonly hit. ## 9. Verification From bebb102e66021885f7522bb36350890f7af62edf Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 17 Aug 2026 15:00:52 +0200 Subject: [PATCH 07/42] feat(apply): unify --dry-run's output and add agent-mode JSON Merges --dry-run's validation preview with --since's deletion preview into one per-file listing (previously two separately-headed sections that repeated a file's path when it had both a surviving and a removed document), sorted by id/origin within each file. Every line now reads "Apply|Delete "" ()", including for a deleted asset -- its name is resolved by re-reading the asset's content from git history at --since's ref (internal/git's ReadFileAtRef), falling back to a "" placeholder only if that lookup fails. Extends the same name lookup to the real (non-dry-run) per-asset deletion confirmation prompt and success/decline messages. Adds agent-mode JSON output for --dry-run: an array of {path, changes: [{op, name, originOrId}]}, covering the plain, --since-merged, and single-file-target cases uniformly. Factors the row-building/grouping logic previously duplicated between apply.go and since.go into a shared buildDryRunRows so text and JSON rendering cannot drift from each other. --- internal/apply/apply.go | 49 ++---- internal/apply/dryrun.go | 210 +++++++++++++++++++++++ internal/apply/dryrun_test.go | 115 +++++++++++++ internal/apply/since.go | 91 ++++++++-- internal/apply/since_integration_test.go | 6 +- internal/apply/since_test.go | 97 ++++++++++- 6 files changed, 506 insertions(+), 62 deletions(-) create mode 100644 internal/apply/dryrun.go create mode 100644 internal/apply/dryrun_test.go diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 939cb964..84bbfdba 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -215,13 +215,7 @@ func runApply(ctx context.Context, flags *applyFlags) error { } if flags.DryRun { - if err := printDryRun(documents, fromDirectory); err != nil { - return err - } - if deletionPlan != nil { - printDeletionPreview(deletionPlan) - } - return nil + return runDryRun(documents, fromDirectory, flags.File, flags.Since, deletionPlan) } // Create API client @@ -331,37 +325,6 @@ func validateDocuments(documents []assetDocument) (validationErrors, validationW return validationErrors, validationWarnings } -func printDryRun(documents []assetDocument, fromDirectory bool) error { - if !fromDirectory { - fmt.Printf("Dry run: %s validated\n", pluralize(len(documents), "document")) - for i, doc := range documents { - fmt.Printf(" %d. %s %s\n", i+1, asset.KindDisplayName(doc.kind), formatNameAndId(doc.name, doc.id)) - } - return nil - } - - // Count unique files - fileSet := make(map[string]bool) - for _, doc := range documents { - fileSet[doc.filePath] = true - } - fmt.Printf("Dry run: %s from %s validated\n", pluralize(len(documents), "document"), pluralize(len(fileSet), "file")) - - // Group by file, preserving order - var currentFile string - docInFile := 0 - for _, doc := range documents { - if doc.filePath != currentFile { - currentFile = doc.filePath - docInFile = 0 - fmt.Printf(" %s\n", doc.filePath) - } - docInFile++ - fmt.Printf(" %d. %s %s\n", docInFile, asset.KindDisplayName(doc.kind), formatNameAndId(doc.name, doc.id)) - } - return nil -} - // validationError formats one or more validation issues into a consistent // "validation failed with N error/errors:" message. func validationError(issues ...string) error { @@ -554,6 +517,16 @@ func readMultiDocumentYAML(filePath string, stdin io.Reader) ([]assetDocument, e } } + return parseMultiDocumentYAML(data) +} + +// parseMultiDocumentYAML splits data on YAML document boundaries and parses +// each into an assetDocument. Factored out of readMultiDocumentYAML so +// callers that already have file content in memory (e.g. --since's +// git-history name lookups, which read a blob via ReadFileAtRef rather than +// a path on disk) can reuse the same parsing without a round trip through +// the filesystem. +func parseMultiDocumentYAML(data []byte) ([]assetDocument, error) { var documents []assetDocument decoder := yaml.NewDecoder(bytes.NewReader(data)) diff --git a/internal/apply/dryrun.go b/internal/apply/dryrun.go new file mode 100644 index 00000000..310d02cc --- /dev/null +++ b/internal/apply/dryrun.go @@ -0,0 +1,210 @@ +package apply + +import ( + "encoding/json" + "fmt" + "os" + "sort" + + dash0yaml "github.com/dash0hq/dash0-api-client-go/yaml" + "github.com/dash0hq/dash0-cli/internal/agentmode" + "github.com/dash0hq/dash0-cli/internal/asset" +) + +// dryRunRow is one asset --dry-run reports on: either being validated +// (create/update) or, when --since is set, removed from -f's contents. +// Rows within a file are sorted by originOrID rather than input order, so a +// file with both a surviving and a removed asset presents them together +// instead of as two separately-headed sections. +type dryRunRow struct { + op string // "apply" or "delete" + kind string + name string + originOrID string + // detail carries the alert-deletion case's extra context (which + // PrometheusRule CRD the alert was removed from) for text rendering + // only -- the JSON schema's {op, name, originOrId} shape has no field + // for it. + detail string +} + +// dryRunChangeJSON and dryRunFileJSON are --agent-mode --dry-run's JSON +// output shape: an array of {path, changes}, one entry per file, each +// change naming the operation, the asset's display name, and its id/origin. +type dryRunChangeJSON struct { + Op string `json:"op"` + Name string `json:"name"` + OriginOrID string `json:"originOrId"` +} + +type dryRunFileJSON struct { + Path string `json:"path"` + Changes []dryRunChangeJSON `json:"changes"` +} + +// buildDryRunRows groups documents (always) and dp's deletion plan (only +// when dp is non-nil) into per-file rows, sorted within each file by +// originOrID (id/origin) for both text and JSON rendering to share. +func buildDryRunRows(documents []assetDocument, dp *deletionPlan) (rowsByFile map[string][]dryRunRow, files []string, validatedFileSet map[string]bool) { + rowsByFile = map[string][]dryRunRow{} + validatedFileSet = map[string]bool{} + addRow := func(file string, row dryRunRow) { + if _, seen := rowsByFile[file]; !seen { + files = append(files, file) + } + rowsByFile[file] = append(rowsByFile[file], row) + } + + // Needed to place an alert deletion (identified only by its surviving + // CRD's identifier, not a file path) under the same file as the CRD's + // own validated entry. + crdFileByIdentifier := map[string]string{} + for _, doc := range documents { + identifier, err := dash0yaml.ExtractIdentifier(doc.raw) + if err != nil || identifier == "" { + identifier = doc.id + } + if normalizeKind(doc.kind) == "prometheusrule" { + crdFileByIdentifier[identifier] = doc.filePath + } + validatedFileSet[doc.filePath] = true + addRow(doc.filePath, dryRunRow{op: "apply", kind: doc.kind, name: doc.name, originOrID: identifier}) + } + + if dp != nil { + for _, d := range dp.plan.ByIdentifier { + // dp.names is resolved from git history and best-effort: a + // lookup failure (rewritten/gc'd blob) falls back to an + // explicit "" placeholder rather than silently omitting + // it. + name := dp.names[d.Path] + if name == "" { + name = "" + } + basePath, _ := splitMultiDocPath(d.Path) + addRow(basePath, dryRunRow{op: "delete", kind: d.Kind, name: name, originOrID: d.Identifier}) + } + for _, a := range dp.plan.AlertsByName { + addRow(crdFileByIdentifier[a.CRDIdentifier], dryRunRow{ + op: "delete", + kind: "checkrule", + name: a.CheckRuleName(), + originOrID: a.CRDIdentifier, + detail: fmt.Sprintf("alert removed from PrometheusRule %s", a.CRDIdentifier), + }) + } + } + + sort.Strings(files) + for _, f := range files { + rows := rowsByFile[f] + sort.SliceStable(rows, func(i, j int) bool { return rows[i].originOrID < rows[j].originOrID }) + } + return rowsByFile, files, validatedFileSet +} + +// runDryRun renders --dry-run's output: validation results, merged with +// --since's deletion plan when dp is non-nil. Emits agent-mode JSON when +// active, plain text otherwise. +func runDryRun(documents []assetDocument, fromDirectory bool, fileArg, since string, dp *deletionPlan) error { + if dp != nil && dp.warning != "" { + fmt.Fprintf(os.Stderr, "warning: %s\n", dp.warning) + } + + rowsByFile, files, validatedFileSet := buildDryRunRows(documents, dp) + + if agentmode.Enabled { + return renderDryRunJSON(rowsByFile, files, fromDirectory, fileArg) + } + renderDryRunText(rowsByFile, files, validatedFileSet, fromDirectory, len(documents), since, dp) + return nil +} + +func renderDryRunText(rowsByFile map[string][]dryRunRow, files []string, validatedFileSet map[string]bool, fromDirectory bool, documentCount int, since string, dp *deletionPlan) { + switch { + case dp == nil && fromDirectory: + fmt.Printf("Dry run: %s from %s validated\n", pluralize(documentCount, "document"), pluralize(len(validatedFileSet), "file")) + case dp == nil: + fmt.Printf("Dry run: %s validated\n", pluralize(documentCount, "document")) + default: + deletionCount := len(dp.plan.ByIdentifier) + len(dp.plan.AlertsByName) + if deletionCount == 0 { + fmt.Printf("Dry run: %s%s validated; --since: no deletions\n", pluralize(documentCount, "document"), fileSuffix(fromDirectory, len(validatedFileSet))) + } else { + fmt.Printf("Dry run: %s%s validated; %s pending due to --since '%s'\n", + pluralize(documentCount, "document"), fileSuffix(fromDirectory, len(validatedFileSet)), pluralize(deletionCount, "deletion"), since) + } + } + + if !fromDirectory { + var flat []dryRunRow + for _, f := range files { + flat = append(flat, rowsByFile[f]...) + } + sort.SliceStable(flat, func(i, j int) bool { return flat[i].originOrID < flat[j].originOrID }) + for _, r := range flat { + fmt.Printf(" * %s\n", renderDryRunLine(r)) + } + return + } + + for _, f := range files { + fmt.Printf(" %s\n", f) + for _, r := range rowsByFile[f] { + fmt.Printf(" * %s\n", renderDryRunLine(r)) + } + } +} + +func renderDryRunLine(r dryRunRow) string { + verb := "Apply" + if r.op == "delete" { + verb = "Delete" + } + if r.detail != "" { + return fmt.Sprintf("%s %s %q (%s)", verb, asset.KindDisplayName(r.kind), r.name, r.detail) + } + return fmt.Sprintf("%s %s %s", verb, asset.KindDisplayName(r.kind), formatNameAndId(r.name, r.originOrID)) +} + +// fileSuffix renders the " from N files" clause the --since summary line +// adds only when the target was a directory — a single-file or stdin target +// has no file count worth stating. +func fileSuffix(fromDirectory bool, fileCount int) string { + if !fromDirectory { + return "" + } + return " from " + pluralize(fileCount, "file") +} + +// renderDryRunJSON emits the {path, changes} array agent mode expects. A +// single-file or stdin target (!fromDirectory) has no real per-document file +// grouping to report, so every row is collected under one entry keyed by the +// literal -f argument. +func renderDryRunJSON(rowsByFile map[string][]dryRunRow, files []string, fromDirectory bool, fileArg string) error { + toChanges := func(rows []dryRunRow) []dryRunChangeJSON { + changes := make([]dryRunChangeJSON, 0, len(rows)) + for _, r := range rows { + changes = append(changes, dryRunChangeJSON{Op: r.op, Name: r.name, OriginOrID: r.originOrID}) + } + return changes + } + + out := []dryRunFileJSON{} + if !fromDirectory { + var flat []dryRunRow + for _, f := range files { + flat = append(flat, rowsByFile[f]...) + } + sort.SliceStable(flat, func(i, j int) bool { return flat[i].originOrID < flat[j].originOrID }) + out = append(out, dryRunFileJSON{Path: fileArg, Changes: toChanges(flat)}) + } else { + for _, f := range files { + out = append(out, dryRunFileJSON{Path: f, Changes: toChanges(rowsByFile[f])}) + } + } + + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(out) +} diff --git a/internal/apply/dryrun_test.go b/internal/apply/dryrun_test.go new file mode 100644 index 00000000..704bb1da --- /dev/null +++ b/internal/apply/dryrun_test.go @@ -0,0 +1,115 @@ +package apply + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/dash0hq/dash0-cli/internal/agentmode" + gitutil "github.com/dash0hq/dash0-cli/internal/git" + "github.com/dash0hq/dash0-cli/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func withAgentMode(t *testing.T, enabled bool) { + t.Helper() + prev := agentmode.Enabled + agentmode.Enabled = enabled + t.Cleanup(func() { agentmode.Enabled = prev }) +} + +// TestRunDryRun_JSON_PlainNoSince pins the agent-mode JSON shape for a plain +// --dry-run (no --since): an array of {path, changes}, one entry per file, +// every change carrying op "apply". +func TestRunDryRun_JSON_PlainNoSince(t *testing.T) { + withAgentMode(t, true) + + documents := []assetDocument{ + {kind: "dashboard", name: "Kept Dashboard", id: "11111111-1111-1111-1111-111111111111", filePath: "dashboard.yaml"}, + } + + stdout := testutil.CaptureStdout(t, func() { + require.NoError(t, runDryRun(documents, true, "dir", "", nil)) + }) + + var out []dryRunFileJSON + require.NoError(t, json.Unmarshal([]byte(stdout), &out)) + require.Len(t, out, 1) + assert.Equal(t, "dashboard.yaml", out[0].Path) + require.Len(t, out[0].Changes, 1) + assert.Equal(t, dryRunChangeJSON{Op: "apply", Name: "Kept Dashboard", OriginOrID: "11111111-1111-1111-1111-111111111111"}, out[0].Changes[0]) +} + +// TestRunDryRun_JSON_MergedWithDeletions pins the JSON shape when a file has +// both a surviving (apply) and a removed (delete) asset -- they must appear +// as two entries in the same file's changes array, not as separate file +// entries. +func TestRunDryRun_JSON_MergedWithDeletions(t *testing.T) { + withAgentMode(t, true) + + documents := []assetDocument{ + {kind: "view", name: "error-logs-view", id: "33333333-3333-3333-3333-333333333333", filePath: "assets.yaml"}, + } + dp := &deletionPlan{ + plan: gitutil.DeletionPlan{ + ByIdentifier: []gitutil.Deletion{ + {Kind: "checkrule", Identifier: "44444444-4444-4444-4444-444444444444", Path: "assets.yaml#1"}, + }, + }, + names: map[string]string{"assets.yaml#1": "High Error Rate"}, + } + + stdout := testutil.CaptureStdout(t, func() { + require.NoError(t, runDryRun(documents, true, "dir", "abc123", dp)) + }) + + var out []dryRunFileJSON + require.NoError(t, json.Unmarshal([]byte(stdout), &out)) + require.Len(t, out, 1) + assert.Equal(t, "assets.yaml", out[0].Path) + require.Len(t, out[0].Changes, 2) + assert.Equal(t, dryRunChangeJSON{Op: "apply", Name: "error-logs-view", OriginOrID: "33333333-3333-3333-3333-333333333333"}, out[0].Changes[0]) + assert.Equal(t, dryRunChangeJSON{Op: "delete", Name: "High Error Rate", OriginOrID: "44444444-4444-4444-4444-444444444444"}, out[0].Changes[1]) +} + +// TestRunDryRun_JSON_SingleFileTarget pins that a single-file (non-directory) +// -f target reports one file entry keyed by the literal -f argument, since +// there is no real per-document file grouping to report. +func TestRunDryRun_JSON_SingleFileTarget(t *testing.T) { + withAgentMode(t, true) + + documents := []assetDocument{ + {kind: "dashboard", name: "Solo Dashboard", id: "id-1"}, + } + + stdout := testutil.CaptureStdout(t, func() { + require.NoError(t, runDryRun(documents, false, "dashboard.yaml", "", nil)) + }) + + var out []dryRunFileJSON + require.NoError(t, json.Unmarshal([]byte(stdout), &out)) + require.Len(t, out, 1) + assert.Equal(t, "dashboard.yaml", out[0].Path) + require.Len(t, out[0].Changes, 1) + assert.Equal(t, "apply", out[0].Changes[0].Op) +} + +// TestRunDryRun_TextMode_Unaffected confirms agent mode's JSON path is +// opt-in only -- with agent mode disabled, runDryRun still renders the +// existing plain-text output. +func TestRunDryRun_TextMode_Unaffected(t *testing.T) { + withAgentMode(t, false) + + documents := []assetDocument{ + {kind: "dashboard", name: "Kept Dashboard", id: "11111111-1111-1111-1111-111111111111", filePath: "dashboard.yaml"}, + } + + stdout := testutil.CaptureStdout(t, func() { + require.NoError(t, runDryRun(documents, true, "dir", "", nil)) + }) + + assert.Contains(t, stdout, "Dry run: 1 document from 1 file validated") + assert.Contains(t, stdout, `Apply Dashboard "Kept Dashboard" (11111111-1111-1111-1111-111111111111)`) + assert.False(t, bytes.HasPrefix([]byte(stdout), []byte("[")), "text mode must not emit JSON") +} diff --git a/internal/apply/since.go b/internal/apply/since.go index 09c3662f..4abe9e97 100644 --- a/internal/apply/since.go +++ b/internal/apply/since.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" dash0api "github.com/dash0hq/dash0-api-client-go" @@ -20,6 +21,15 @@ import ( type deletionPlan struct { plan gitutil.DeletionPlan warning string + // names best-effort maps each ByIdentifier deletion's Path (the + // git-recorded path, unique within one plan) to its display name, + // resolved by re-reading the asset's content from git history at the + // --since ref. A missing entry means the lookup failed (e.g. a rewritten + // or gc'd blob) or the asset had no name in the first place; callers + // fall back to a placeholder. This is cosmetic only — deletion dispatch + // (deleteAssetByKindAndIdentifier) never depends on it, only on + // (kind, identifier). + names map[string]string } // computeDeletionPlan resolves flags.Since against the git repository @@ -114,26 +124,61 @@ func computeDeletionPlan(ctx context.Context, flags *applyFlags) (*deletionPlan, flags.Since, pluralize(len(plan.NoIdentifier), "document"), strings.Join(plan.NoIdentifier, "\n ")) } - return &deletionPlan{plan: plan, warning: warning}, nil + names := resolveDeletionNames(ctx, repo, sha, plan.ByIdentifier) + + return &deletionPlan{plan: plan, warning: warning, names: names}, nil } -func printDeletionPreview(dp *deletionPlan) { - if dp.warning != "" { - fmt.Fprintf(os.Stderr, "warning: %s\n", dp.warning) - } - if dp.plan.IsEmpty() { - fmt.Println("--since: no deletions") - return - } - fmt.Println("--since would delete:") - for _, d := range dp.plan.ByIdentifier { - fmt.Printf(" - %s (%s)\n", asset.KindDisplayName(d.Kind), d.Identifier) +// resolveDeletionNames best-effort looks up each deletion's display name by +// re-reading its content from git history at sha (the resolved --since +// ref). Reads are cached per file so a multi-document file with several +// deletion candidates only costs one `git cat-file` call. +// +// This is display polish, not correctness-critical: a lookup failure (a +// since-rewritten blob, content that no longer parses under today's rules) +// just omits that entry from the returned map rather than failing the run — +// --since's actual deletion dispatch never depends on a name, only on +// (kind, identifier). +func resolveDeletionNames(ctx context.Context, repo gitutil.Repo, sha string, deletions []gitutil.Deletion) map[string]string { + names := make(map[string]string, len(deletions)) + docsByFile := map[string][]assetDocument{} + for _, d := range deletions { + basePath, docIndex := splitMultiDocPath(d.Path) + docs, cached := docsByFile[basePath] + if !cached { + raw, err := repo.ReadFileAtRef(ctx, sha, basePath) + if err == nil { + docs, _ = parseMultiDocumentYAML(raw) + } + docsByFile[basePath] = docs + } + if docIndex < len(docs) && docs[docIndex].name != "" { + names[d.Path] = docs[docIndex].name + } } - for _, a := range dp.plan.AlertsByName { - fmt.Printf(" - Check rule %q (alert removed from PrometheusRule %s)\n", a.CheckRuleName(), a.CRDIdentifier) + return names +} + +// splitMultiDocPath splits a Deletion.Path — possibly suffixed "#" +// for the second and later documents in a multi-document file, per +// internal/git/snapshot.go's ingestDocuments — into the base file path and +// the document's 0-based index within it, matching parseMultiDocumentYAML's +// return-slice indexing. +func splitMultiDocPath(path string) (basePath string, docIndex int) { + idx := strings.LastIndex(path, "#") + if idx == -1 { + return path, 0 + } + n, err := strconv.Atoi(path[idx+1:]) + if err != nil { + return path, 0 } + return path[:idx], n } +// Rendering (text and agent-mode JSON) for --dry-run, with or without a +// deletion plan, lives in dryrun.go. + // applyDeletions carries out dp's deletion plan against the Dash0 API, // prompting per asset (skipped when force is set) exactly like every // standalone ` delete --force`. It returns the number of deletions the @@ -149,23 +194,31 @@ func applyDeletions(ctx context.Context, apiClient dash0api.Client, dataset *str for _, d := range dp.plan.ByIdentifier { displayKind := asset.KindDisplayName(d.Kind) + // dp.names is resolved from git history and best-effort: a lookup + // failure falls back to an explicit "" placeholder, matching + // printDryRunWithDeletions' convention. + name := dp.names[d.Path] + if name == "" { + name = "" + } + display := formatNameAndId(name, d.Identifier) if d.Kind == "spamfilter" && !d.SpamFilterUsesOrigin { - fmt.Fprintf(os.Stderr, "warning: spam filter %q was identified by dash0.com/id alone; its live id may have been reassigned by the server since this identifier was recorded (see docs/commands.md's asset-identifiers section), so this delete may miss the actual live filter\n", d.Identifier) + fmt.Fprintf(os.Stderr, "warning: spam filter %s was identified by dash0.com/id alone; its live id may have been reassigned by the server since this identifier was recorded (see docs/commands.md's asset-identifiers section), so this delete may miss the actual live filter\n", display) } - prompt := fmt.Sprintf("Are you sure you want to delete %s %q, removed since --since ref? [y/N]: ", displayKind, d.Identifier) + prompt := fmt.Sprintf("Are you sure you want to delete %s %s, removed since --since ref? [y/N]: ", displayKind, display) confirmed, err := confirmation.ConfirmDestructiveOperation(ctx, prompt, force) if err != nil { return declined, err } if !confirmed { - fmt.Fprintf(os.Stderr, "%s %q: deletion declined\n", displayKind, d.Identifier) + fmt.Fprintf(os.Stderr, "%s %s: deletion declined\n", displayKind, display) declined++ continue } if err := deleteAssetByKindAndIdentifier(ctx, apiClient, dataset, d, force); err != nil { - return declined, fmt.Errorf("failed to delete %s %q: %w", displayKind, d.Identifier, err) + return declined, fmt.Errorf("failed to delete %s %s: %w", displayKind, display, err) } - fmt.Printf("%s %q deleted\n", displayKind, d.Identifier) + fmt.Printf("%s %s deleted\n", displayKind, display) } for _, a := range dp.plan.AlertsByName { diff --git a/internal/apply/since_integration_test.go b/internal/apply/since_integration_test.go index 2ed3d41f..17d11f62 100644 --- a/internal/apply/since_integration_test.go +++ b/internal/apply/since_integration_test.go @@ -3,6 +3,7 @@ package apply import ( + "fmt" "net/http" "os" "path/filepath" @@ -823,8 +824,9 @@ spec: }) require.NoError(t, cmdErr) - assert.Contains(t, output, "would delete") + assert.Contains(t, output, fmt.Sprintf("pending due to --since '%s'", before)) assert.Contains(t, output, "a1b2c3d4-5678-90ab-cdef-1234567890ab") + assert.Contains(t, output, "Delete Dashboard") } // TestApply_Since_SpamFilterIDOnlyDeletionWarns is a regression test for a @@ -891,7 +893,7 @@ spec: }) require.NoError(t, cmdErr) - assert.Contains(t, stderr, "spam filter \"spam-id-only\" was identified by dash0.com/id alone") + assert.Contains(t, stderr, "spam filter \"Drop noisy health checks\" (spam-id-only) was identified by dash0.com/id alone") } // TestApply_Since_SpamFilterOriginDeletionDoesNotWarn confirms the warning diff --git a/internal/apply/since_test.go b/internal/apply/since_test.go index c9b94b80..b165cf92 100644 --- a/internal/apply/since_test.go +++ b/internal/apply/since_test.go @@ -136,9 +136,100 @@ func TestComputeDeletionPlan_WholeFileDeletion(t *testing.T) { dp, err := computeDeletionPlan(context.Background(), flags) require.NoError(t, err) require.Len(t, dp.plan.ByIdentifier, 1) - assert.Equal(t, "dashboard", dp.plan.ByIdentifier[0].Kind) - assert.Equal(t, "a1b2c3d4-5678-90ab-cdef-1234567890ab", dp.plan.ByIdentifier[0].Identifier) + deletion := dp.plan.ByIdentifier[0] + assert.Equal(t, "dashboard", deletion.Kind) + assert.Equal(t, "a1b2c3d4-5678-90ab-cdef-1234567890ab", deletion.Identifier) assert.Empty(t, dp.warning) + // dp.names is resolved from git history at the --since ref, not from + // current disk contents (the file no longer exists on disk). + assert.Equal(t, "My Dashboard", dp.names[deletion.Path]) +} + +// TestSplitMultiDocPath is a table test for the "#" suffix +// internal/git/snapshot.go appends to the second and later documents' paths +// in a multi-document file. +func TestSplitMultiDocPath(t *testing.T) { + cases := []struct { + path string + wantBase string + wantDocIndex int + }{ + {"assets.yaml", "assets.yaml", 0}, + {"assets.yaml#1", "assets.yaml", 1}, + {"assets.yaml#12", "assets.yaml", 12}, + // A literal "#" not followed by digits is not a multi-document + // suffix -- treat the whole string as the path. + {"weird#name.yaml", "weird#name.yaml", 0}, + } + for _, c := range cases { + base, idx := splitMultiDocPath(c.path) + assert.Equal(t, c.wantBase, base, "path %q", c.path) + assert.Equal(t, c.wantDocIndex, idx, "path %q", c.path) + } +} + +// TestResolveDeletionNames_MultiDocumentFile is a regression test for a bug +// where a deletion candidate from the second (or later) document in a +// multi-document file failed to resolve a name at all, because its Path +// carries a "#" suffix that doesn't match any real git blob path — +// resolveDeletionNames must strip the suffix to read the file, then use the +// index to pick the right document out of the file's content. +func TestResolveDeletionNames_MultiDocumentFile(t *testing.T) { + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "assets.yaml", `apiVersion: dash0.com/v1alpha1 +kind: View +metadata: + name: first-view + labels: + dash0.com/id: view-id +spec: + query: "true" +--- +apiVersion: dash0.com/v1alpha1 +kind: CheckRule +id: rule-id +name: Second Document Rule +expression: up == 0 +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "seed") + + repo := gitutil.Repo{Dir: dir} + deletions := []gitutil.Deletion{ + {Kind: "view", Identifier: "view-id", Path: "assets.yaml"}, + {Kind: "checkrule", Identifier: "rule-id", Path: "assets.yaml#1"}, + } + names := resolveDeletionNames(context.Background(), repo, "HEAD", deletions) + assert.Equal(t, "first-view", names["assets.yaml"]) + assert.Equal(t, "Second Document Rule", names["assets.yaml#1"]) +} + +// TestResolveDeletionNames_LookupFailureIsNonFatal is a regression test +// pinning that a git-read failure (an unresolvable ref, a path that never +// existed) only omits that entry from the returned map -- it must never +// panic or error the caller, since name resolution is display polish, not +// something --since's actual deletion dispatch depends on. +func TestResolveDeletionNames_LookupFailureIsNonFatal(t *testing.T) { + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + writeFileFixture(t, dir, "placeholder.yaml", "kind: View\nmetadata:\n name: x\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "seed") + + repo := gitutil.Repo{Dir: dir} + deletions := []gitutil.Deletion{ + {Kind: "dashboard", Identifier: "gone-id", Path: "never-existed.yaml"}, + } + names := resolveDeletionNames(context.Background(), repo, "HEAD", deletions) + assert.Empty(t, names) } func TestComputeDeletionPlan_EmptyRef(t *testing.T) { @@ -257,7 +348,7 @@ func TestApplyDeletions_PrometheusRuleConfirmationPromptUsesConsistentCasing(t * assert.Equal(t, 1, declined) }) - assert.Contains(t, stdout, "Are you sure you want to delete PrometheusRule \"shared-id\"") + assert.Contains(t, stdout, "Are you sure you want to delete PrometheusRule \"\" (shared-id)") assert.NotContains(t, stdout, "prometheusrule", "the whole display name must never be force-lowercased into an unreadable compound word") } From 1428e5763c5799b4c95c911c0e369ae79c47fb94 Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 17 Aug 2026 15:02:35 +0200 Subject: [PATCH 08/42] chore: add changelog entry for apply --since/--force --- .chloggen/feat_sync-action.yaml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .chloggen/feat_sync-action.yaml diff --git a/.chloggen/feat_sync-action.yaml b/.chloggen/feat_sync-action.yaml new file mode 100644 index 00000000..70acff92 --- /dev/null +++ b/.chloggen/feat_sync-action.yaml @@ -0,0 +1,32 @@ +# 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: "Add `apply --since ` and `--force` for git-history-based deletion sync" + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [0] + +# (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: | + Deletes assets whose definition existed at `` but is no longer present in `-f`'s + current contents, detected by identifier (id or origin), never by file path. Requires + `--experimental`/`-X`. `--dry-run --since` previews the deletion plan, merged with the + existing create/update preview into one per-file listing, and now also resolves deleted + assets' names from git history instead of only showing their id. Agent mode emits + `--dry-run`'s preview as JSON. + +# 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: [] From 5505509311b0c863504132f6fdee554dfc29452b Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 17 Aug 2026 15:03:18 +0200 Subject: [PATCH 09/42] chore: reference PR #253 in changelog entry --- .chloggen/feat_sync-action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.chloggen/feat_sync-action.yaml b/.chloggen/feat_sync-action.yaml index 70acff92..eb634ef5 100644 --- a/.chloggen/feat_sync-action.yaml +++ b/.chloggen/feat_sync-action.yaml @@ -10,7 +10,7 @@ component: apply note: "Add `apply --since ` and `--force` for git-history-based deletion sync" # Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. -issues: [0] +issues: [253] # (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. From 648154851f7a708e25cb178d8ed374ec9a2ffef4 Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 17 Aug 2026 15:29:02 +0200 Subject: [PATCH 10/42] test(apply): add roundtrip tests for apply --since against a live environment Adds three roundtrip scripts run against a real Dash0 environment, closing the one test tier --since previously only had mock-server/e2e-container coverage for: whole-file and multi-document partial deletion, apply --since idempotency (second run against the new baseline reports no changes), and the all-zeros-sentinel / non-ancestor-ref edge cases (including the --force confirmation bypass). Registers all three in run_all.sh's API_TESTS list. Deliberately does not cover PrometheusRule alerting-rule partial removal: verified against the real API that a CRD with 2+ alerts sharing one dash0.com/id never produces more than one live check rule via create/apply (each alert's PUT overwrites the previous one under the shared id) -- filed as #254, since it's a pre-existing bug in the sibling dash0-api-client-go's CRD conversion, not something --since introduced, but it does mean that scenario has no real-world-reachable coverage beyond the existing mock-server-based tests. --- .../changes/add-diff-and-since-flag/tasks.md | 12 +- test/roundtrip/run_all.sh | 3 + .../roundtrip/test_apply_since_idempotency.sh | 93 ++++++++++ .../test_apply_since_ref_edge_cases.sh | 127 +++++++++++++ test/roundtrip/test_apply_since_roundtrip.sh | 172 ++++++++++++++++++ 5 files changed, 401 insertions(+), 6 deletions(-) create mode 100755 test/roundtrip/test_apply_since_idempotency.sh create mode 100755 test/roundtrip/test_apply_since_ref_edge_cases.sh create mode 100755 test/roundtrip/test_apply_since_roundtrip.sh diff --git a/openspec/changes/add-diff-and-since-flag/tasks.md b/openspec/changes/add-diff-and-since-flag/tasks.md index 6925ccd9..8add9ed3 100644 --- a/openspec/changes/add-diff-and-since-flag/tasks.md +++ b/openspec/changes/add-diff-and-since-flag/tasks.md @@ -90,12 +90,12 @@ Everything above tests either in-process (unit tests share the Go test binary; i ## 7. Roundtrip tests -- [ ] 7.1 `test/roundtrip/test_apply_since_roundtrip.sh`: against a real Dash0 environment and a real git repo (create commits, run `dash0 apply --since --experimental`), covering at minimum a whole-file deletion, a multi-document YAML partial deletion, and a PrometheusRule alerting-rule partial deletion (name-based lookup) — the three deletion mechanisms from Sections 1 and 4. -- [ ] 7.2 `test/roundtrip/test_apply_since_idempotency.sh`: run `apply --since --experimental` once (applying the pending creates/updates/deletes), commit the resulting state as the new baseline, then run `apply --since --experimental` again against it — the second run reports no changes, proving `--since` doesn't repeat a deletion or error on an already-gone asset. Mirrors this project's existing `test_apply__idempotency.sh` pattern for asset creates/updates (see `docs/testing.md`). -- [ ] 7.3 `test/roundtrip/test_apply_since_ref_edge_cases.sh`: the all-zeros SHA sentinel (first push to a new branch in the test fixture) and a non-ancestor ref (simulate a force-push in the fixture repo), confirming the specific error messages and the confirmation-prompt/`--force`-bypass behavior against a real Dash0 environment, not just at the unit/e2e level. -- [ ] 7.4 `test/roundtrip/test_diff_roundtrip.sh`: against a real Dash0 environment, covering a create preview, an update preview, and a `--since` deletion preview (all invocations passing `--experimental`), confirming `dash0 diff`'s exit code (`0`/`1`/`2`) at each step and that nothing is ever written. -- [ ] 7.5 Register all four new scripts in `test/roundtrip/run_all.sh`'s `API_TESTS` list (per `docs/testing.md` — CI discovers `test_*.sh` automatically, but the project's convention is to register explicitly too). -- [ ] 7.6 `make test-roundtrip` passes with the new scripts included. +- [x] 7.1 `test/roundtrip/test_apply_since_roundtrip.sh`: against a real Dash0 environment (the `minecraft` dev profile) and a real git repo, covering a whole-file deletion and a multi-document YAML partial deletion. **Deviation:** the PrometheusRule alerting-rule partial deletion scenario is not covered — while writing it, found that a CRD with 2+ alerts sharing one `dash0.com/id` never produces more than one live check rule via `create`/`apply` in the first place (each alert's PUT overwrites the previous one under the shared id), so the "one alert removed while another survives as its own check rule" scenario cannot be constructed against the real API. Filed as [dash0hq/dash0-cli#254](https://github.com/dash0hq/dash0-cli/issues/254); this is a pre-existing bug in the sibling `dash0-api-client-go`'s CRD-to-check-rule conversion, not something `--since` introduced, but it does mean 1.6/4.7's alert-partial-removal path has no real-world-reachable coverage beyond the mock-server-based unit/integration tests. +- [x] 7.2 `test/roundtrip/test_apply_since_idempotency.sh`: run `apply --since --experimental --force` once, then again against the new baseline — the second run reports no changes and does not error on the already-deleted asset. +- [x] 7.3 `test/roundtrip/test_apply_since_ref_edge_cases.sh`: the all-zeros SHA sentinel and a non-ancestor ref (simulated force-push), covering the no-`--force`/no-terminal hard-fail and the `--force` bypass, against the real `minecraft` environment. +- [ ] 7.4 `test/roundtrip/test_diff_roundtrip.sh`: against a real Dash0 environment, covering a create preview, an update preview, and a `--since` deletion preview (all invocations passing `--experimental`), confirming `dash0 diff`'s exit code (`0`/`1`/`2`) at each step and that nothing is ever written. Not done — `dash0 diff` (Section 5) does not exist yet. +- [x] 7.5 Registered the three new scripts (7.1-7.3) in `test/roundtrip/run_all.sh`'s `API_TESTS` list. The fourth (`test_diff_roundtrip.sh`, 7.4) will be registered once `dash0 diff` exists. +- [ ] 7.6 `make test-roundtrip` passes with the new scripts included. Each of the three new scripts was run and verified individually against the `minecraft` profile (all pass, all cleaned up after themselves); the full `make test-roundtrip` suite (which also runs every other asset type's roundtrip tests) was not run in this pass. - [ ] 7.7 A backward-compat-style scenario (mirroring `Test_BackwardCompatWithExperimentalFlag` from `docs/promoting-commands-to-stable.md`) is not needed yet — that pattern applies at promotion time, once `-X` is no longer required. Note it here so whoever promotes `--since`/`diff` to stable knows to add it then. ## 8. Documentation diff --git a/test/roundtrip/run_all.sh b/test/roundtrip/run_all.sh index 10618495..164be2ae 100755 --- a/test/roundtrip/run_all.sh +++ b/test/roundtrip/run_all.sh @@ -79,6 +79,9 @@ API_TESTS=( "${SCRIPT_DIR}/test_spam_filter_concurrent_create.sh" "${SCRIPT_DIR}/test_team_roundtrip.sh" "${SCRIPT_DIR}/test_team_declarative_roundtrip.sh" + "${SCRIPT_DIR}/test_apply_since_roundtrip.sh" + "${SCRIPT_DIR}/test_apply_since_idempotency.sh" + "${SCRIPT_DIR}/test_apply_since_ref_edge_cases.sh" ) # OTLP-based round-trip tests additionally need DASH0_OTLP_URL. diff --git a/test/roundtrip/test_apply_since_idempotency.sh b/test/roundtrip/test_apply_since_idempotency.sh new file mode 100755 index 00000000..c386bc92 --- /dev/null +++ b/test/roundtrip/test_apply_since_idempotency.sh @@ -0,0 +1,93 @@ +#!/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" +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +SUFFIX="$(uuidgen | tr '[:upper:]' '[:lower:]' | tr -d '-' | cut -c1-12)" +DASHBOARD_ORIGIN="since-idem-dashboard-${SUFFIX}" +REMOVE_ORIGIN="since-idem-remove-${SUFFIX}" + +echo "=== apply --since idempotency test (suffix: $SUFFIX) ===" + +git_repo() { + git -C "$TMPDIR" "$@" +} + +git init -q -b main "$TMPDIR" +git_repo config user.email "roundtrip-test@example.com" +git_repo config user.name "Roundtrip Test" +git_repo config commit.gpgsign false + +cat > "${TMPDIR}/keep.yaml" << YAML +apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: keep-${SUFFIX} + dash0extensions: + id: ${DASHBOARD_ORIGIN} +spec: + display: + name: Since Idempotency Keep ${SUFFIX} + layouts: [] + panels: {} +YAML + +cat > "${TMPDIR}/remove.yaml" << YAML +apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: remove-${SUFFIX} + dash0extensions: + id: ${REMOVE_ORIGIN} +spec: + display: + name: Since Idempotency Remove ${SUFFIX} + layouts: [] + panels: {} +YAML + +echo "--- Step 1: create the 'before' state ---" +git_repo add -A +git_repo commit -q -m "before" +BEFORE_SHA=$(git_repo rev-parse HEAD) +"$DASH0" apply -f "$TMPDIR" > /dev/null + +echo "--- Step 2: remove one dashboard, commit ---" +rm "${TMPDIR}/remove.yaml" +git_repo add -A +git_repo commit -q -m "remove one dashboard" +FIRST_SINCE_SHA=$(git_repo rev-parse HEAD) + +echo "--- Step 3: first apply --since (expect: one deletion) ---" +FIRST_OUTPUT=$("$DASH0" --experimental apply -f "$TMPDIR" --since "$BEFORE_SHA" --force) +echo "$FIRST_OUTPUT" +if ! echo "$FIRST_OUTPUT" | grep -q "$REMOVE_ORIGIN"; then + echo "FAIL: first apply --since did not delete '$REMOVE_ORIGIN'" + exit 1 +fi + +echo "--- Step 4: second apply --since against the new baseline (expect: no deletions) ---" +# Nothing changed between FIRST_SINCE_SHA and now -- the second run must not +# error on an already-gone asset or repeat the deletion. +SECOND_OUTPUT=$("$DASH0" --experimental apply -f "$TMPDIR" --since "$FIRST_SINCE_SHA" --force) +echo "$SECOND_OUTPUT" +if echo "$SECOND_OUTPUT" | grep -qi "deleted"; then + echo "FAIL: second apply --since reported a deletion; expected none" + exit 1 +fi + +echo "--- Step 5: verify the removed dashboard stays gone ---" +if "$DASH0" dashboards list --all -o json | jq -e --arg id "$REMOVE_ORIGIN" '.[] | select(.metadata.dash0Extensions.id == $id)' > /dev/null 2>&1; then + echo "FAIL: dashboard '$REMOVE_ORIGIN' reappeared" + exit 1 +fi + +echo "--- Cleanup ---" +"$DASH0" dashboards delete "$DASHBOARD_ORIGIN" --force || true + +echo "=== apply --since idempotency test PASSED ===" diff --git a/test/roundtrip/test_apply_since_ref_edge_cases.sh b/test/roundtrip/test_apply_since_ref_edge_cases.sh new file mode 100755 index 00000000..b4d3ba08 --- /dev/null +++ b/test/roundtrip/test_apply_since_ref_edge_cases.sh @@ -0,0 +1,127 @@ +#!/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" +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +SUFFIX="$(uuidgen | tr '[:upper:]' '[:lower:]' | tr -d '-' | cut -c1-12)" +DASHBOARD_ORIGIN="since-edge-dashboard-${SUFFIX}" + +echo "=== apply --since ref edge cases test (suffix: $SUFFIX) ===" + +git_repo() { + git -C "$TMPDIR" "$@" +} + +git init -q -b main "$TMPDIR" +git_repo config user.email "roundtrip-test@example.com" +git_repo config user.name "Roundtrip Test" +git_repo config commit.gpgsign false + +cat > "${TMPDIR}/dashboard.yaml" << YAML +apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: edge-${SUFFIX} + dash0extensions: + id: ${DASHBOARD_ORIGIN} +spec: + display: + name: Since Edge Case ${SUFFIX} + layouts: [] + panels: {} +YAML +git_repo add -A +git_repo commit -q -m "initial commit" + +echo "--- Scenario 1: all-zeros sentinel (first push to a new branch) ---" +ZEROS="0000000000000000000000000000000000000000" +if OUTPUT=$("$DASH0" --experimental apply -f "$TMPDIR" --since "$ZEROS" --dry-run 2>&1); then + echo "FAIL: apply --since --dry-run should have failed" + exit 1 +fi +echo "$OUTPUT" +if ! echo "$OUTPUT" | grep -qi "all-zeros"; then + echo "FAIL: expected the all-zeros-specific error message" + exit 1 +fi + +echo "--- Scenario 2: non-ancestor ref (simulated force-push) ---" +cat > "${TMPDIR}/view.yaml" << YAML +apiVersion: dash0.com/v1alpha1 +kind: View +metadata: + name: designated-ref-marker-${SUFFIX} + labels: + dash0.com/id: since-edge-marker-${SUFFIX} +spec: + display: + name: Since Edge Marker ${SUFFIX} + type: logs +YAML +git_repo add -A +git_repo commit -q -m "add designated ref commit" +DESIGNATED_REF=$(git_repo rev-parse HEAD) + +# Simulate a force-push: hard-reset back to the initial commit, then commit +# again, orphaning DESIGNATED_REF (still resolvable by SHA, not an ancestor). +FIRST_COMMIT=$(git_repo log --oneline | tail -1 | awk '{print $1}') +git_repo reset -q --hard "$FIRST_COMMIT" +cat > "${TMPDIR}/other.yaml" << YAML +apiVersion: dash0.com/v1alpha1 +kind: View +metadata: + name: other-view-${SUFFIX} + labels: + dash0.com/id: since-edge-other-${SUFFIX} +spec: + display: + name: Since Edge Other ${SUFFIX} + type: logs +YAML +git_repo add -A +git_repo commit -q -m "post-force-push commit" + +if git_repo merge-base --is-ancestor "$DESIGNATED_REF" HEAD; then + echo "FAIL: test setup broken -- designated ref is still an ancestor of HEAD" + exit 1 +fi + +echo "--- Scenario 2a: no --force, no terminal (expect: creates/updates still apply, only the deletion phase hard-fails) ---" +if OUTPUT=$("$DASH0" --experimental apply -f "$TMPDIR" --since "$DESIGNATED_REF" < /dev/null 2>&1); then + echo "FAIL: apply --since with no terminal and no --force should have failed" + exit 1 +fi +echo "$OUTPUT" +if ! echo "$OUTPUT" | grep -qi "not an ancestor"; then + echo "FAIL: expected the non-ancestor warning/error" + exit 1 +fi + +echo "--- Scenario 2b: --force bypasses the confirmation prompt ---" +FORCE_OUTPUT=$("$DASH0" --experimental apply -f "$TMPDIR" --since "$DESIGNATED_REF" --force 2>&1) +echo "$FORCE_OUTPUT" +if ! echo "$FORCE_OUTPUT" | grep -qi "not an ancestor"; then + echo "FAIL: expected the non-ancestor warning even with --force" + exit 1 +fi +if ! echo "$FORCE_OUTPUT" | grep -q "since-edge-marker-${SUFFIX}"; then + echo "FAIL: --force did not delete the view removed relative to the non-ancestor ref" + exit 1 +fi + +echo "--- Verify the view from the orphaned ref was deleted ---" +if "$DASH0" views list --all -o json | jq -e --arg id "since-edge-marker-${SUFFIX}" '.[] | select(.metadata.labels["dash0.com/id"] == $id or .metadata.labels["dash0.com/origin"] == $id)' > /dev/null 2>&1; then + echo "FAIL: view 'since-edge-marker-${SUFFIX}' still exists after --force deletion" + exit 1 +fi + +echo "--- Cleanup ---" +"$DASH0" views delete "since-edge-other-${SUFFIX}" --force || true +"$DASH0" dashboards delete "$DASHBOARD_ORIGIN" --force || true + +echo "=== apply --since ref edge cases test PASSED ===" diff --git a/test/roundtrip/test_apply_since_roundtrip.sh b/test/roundtrip/test_apply_since_roundtrip.sh new file mode 100755 index 00000000..fd3f251c --- /dev/null +++ b/test/roundtrip/test_apply_since_roundtrip.sh @@ -0,0 +1,172 @@ +#!/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" +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +SUFFIX="$(uuidgen | tr '[:upper:]' '[:lower:]' | tr -d '-' | cut -c1-12)" + +echo "=== apply --since round-trip test (suffix: $SUFFIX) ===" + +# NOTE: a PrometheusRule alerting-rule partial-removal scenario is +# deliberately not covered here -- see +# https://github.com/dash0hq/dash0-cli/issues/254. A CRD with multiple +# alerts sharing one dash0.com/id never produces more than one live check +# rule via ordinary create/apply (each alert's PUT overwrites the previous +# one under that shared id), so the "one alert removed while another +# survives as its own check rule" scenario --since's alert-partial-removal +# detection is designed for cannot be constructed against the real API. + +git_repo() { + git -C "$TMPDIR" "$@" +} + +git init -q -b main "$TMPDIR" +git_repo config user.email "roundtrip-test@example.com" +git_repo config user.name "Roundtrip Test" +git_repo config commit.gpgsign false + +# --------------------------------------------------------------------------- +# Scenario A: whole-file deletion +# --------------------------------------------------------------------------- +DASHBOARD_ORIGIN="since-rt-dashboard-${SUFFIX}" +cat > "${TMPDIR}/keep-dashboard.yaml" << YAML +apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: keep-dashboard-${SUFFIX} + dash0extensions: + id: ${DASHBOARD_ORIGIN} +spec: + display: + name: Since RT Keep Dashboard ${SUFFIX} + layouts: [] + panels: {} +YAML + +REMOVE_DASHBOARD_ORIGIN="since-rt-remove-dashboard-${SUFFIX}" +cat > "${TMPDIR}/remove-dashboard.yaml" << YAML +apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: remove-dashboard-${SUFFIX} + dash0extensions: + id: ${REMOVE_DASHBOARD_ORIGIN} +spec: + display: + name: Since RT Remove Dashboard ${SUFFIX} + layouts: [] + panels: {} +YAML + +# --------------------------------------------------------------------------- +# Scenario B: multi-document partial deletion (a View survives, a CheckRule +# in the same file is removed). +# --------------------------------------------------------------------------- +VIEW_ID="since-rt-view-${SUFFIX}" +CHECKRULE_ID="since-rt-checkrule-${SUFFIX}" +cat > "${TMPDIR}/multi-doc.yaml" << YAML +apiVersion: dash0.com/v1alpha1 +kind: View +metadata: + name: since-rt-view-${SUFFIX} + labels: + dash0.com/id: ${VIEW_ID} +spec: + display: + name: Since RT View ${SUFFIX} + type: logs +--- +apiVersion: dash0.com/v1alpha1 +kind: CheckRule +id: ${CHECKRULE_ID} +name: Since RT Check Rule ${SUFFIX} +expression: up == 0 +YAML + +echo "--- Step 1: Create the 'before' commit (all assets present) ---" +git_repo add -A +git_repo commit -q -m "add before state" +BEFORE_SHA=$(git_repo rev-parse HEAD) +echo "before SHA: $BEFORE_SHA" + +echo "--- Step 2: Apply the 'before' state so the assets actually exist ---" +APPLY_BEFORE=$("$DASH0" apply -f "$TMPDIR") +echo "$APPLY_BEFORE" +if ! echo "$APPLY_BEFORE" | grep -q "created"; then + echo "FAIL: expected 'created' when applying the before state" + exit 1 +fi + +echo "--- Step 3: Mutate the working tree (remove one asset per scenario) ---" +rm "${TMPDIR}/remove-dashboard.yaml" +cat > "${TMPDIR}/multi-doc.yaml" << YAML +apiVersion: dash0.com/v1alpha1 +kind: View +metadata: + name: since-rt-view-${SUFFIX} + labels: + dash0.com/id: ${VIEW_ID} +spec: + display: + name: Since RT View ${SUFFIX} + type: logs +YAML +git_repo add -A +git_repo commit -q -m "remove dashboard and checkrule document" + +echo "--- Step 4: apply --since (expect 2 deletions) ---" +SINCE_OUTPUT=$("$DASH0" --experimental apply -f "$TMPDIR" --since "$BEFORE_SHA" --force) +echo "$SINCE_OUTPUT" + +FAIL=0 +if ! echo "$SINCE_OUTPUT" | grep -q "$REMOVE_DASHBOARD_ORIGIN"; then + echo "FAIL: whole-file dashboard deletion did not mention its origin" + FAIL=1 +fi +if ! echo "$SINCE_OUTPUT" | grep -q "$CHECKRULE_ID"; then + echo "FAIL: multi-document check-rule deletion did not mention its id" + FAIL=1 +fi +if [ "$FAIL" -ne 0 ]; then + exit 1 +fi + +echo "--- Step 5: verify the removed assets are gone ---" +# Both dashboards and check rules are soft-deleted server-side: `get` by id +# keeps returning the record, so absence from `list --all` is the reliable +# deletion signal (matches the other roundtrip tests' convention). +if "$DASH0" dashboards list --all -o json | jq -e --arg id "$REMOVE_DASHBOARD_ORIGIN" '.[] | select(.metadata.dash0Extensions.id == $id)' > /dev/null 2>&1; then + echo "FAIL: dashboard '$REMOVE_DASHBOARD_ORIGIN' still exists after --since deletion" + FAIL=1 +fi +if "$DASH0" check-rules list --all -o json | jq -e --arg id "$CHECKRULE_ID" '.[] | select(.id == $id)' > /dev/null 2>&1; then + echo "FAIL: check rule '$CHECKRULE_ID' still exists after --since deletion" + FAIL=1 +fi +if [ "$FAIL" -ne 0 ]; then + exit 1 +fi + +echo "--- Step 6: verify the surviving assets are still present ---" +if ! "$DASH0" dashboards get "$DASHBOARD_ORIGIN" > /dev/null 2>&1; then + echo "FAIL: surviving dashboard '$DASHBOARD_ORIGIN' is missing" + FAIL=1 +fi +if ! "$DASH0" views get "$VIEW_ID" > /dev/null 2>&1; then + echo "FAIL: surviving view '$VIEW_ID' is missing" + FAIL=1 +fi +if [ "$FAIL" -ne 0 ]; then + exit 1 +fi + +echo "--- Cleanup: delete the surviving assets ---" +"$DASH0" dashboards delete "$DASHBOARD_ORIGIN" --force || true +"$DASH0" views delete "$VIEW_ID" --force || true + +echo "=== apply --since round-trip test PASSED ===" From ca0e56e34a455fe06bfdb3c5a70e2f1fa6650c7b Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 17 Aug 2026 15:52:40 +0200 Subject: [PATCH 11/42] fix(apply): consistent file grouping for --since deletions under a subdirectory -f target gitutil.Deletion.Path (from git ls-tree) is always repo-root-relative, while assetDocument.filePath is always relative to the -f target itself. When -f points at a subdirectory of the repo rather than the repo root, these two bases diverge: a deletion candidate's path (e.g. "dashboards/removed.yaml") no longer matches its file's surviving documents' path ("keep.yaml"), so the merged --dry-run listing grouped them under different, inconsistently-prefixed entries instead of one entry per file. Threads the --since target's scope (already computed in computeDeletionPlan for the git-side pathspec) through to the dry-run renderer, stripping it from each deletion path before grouping so both sides use the same basis. Found while writing documentation examples for apply --since and noticing the discrepancy against real output. --- internal/apply/dryrun.go | 22 +++++- internal/apply/dryrun_test.go | 92 ++++++++++++++++++++++++ internal/apply/since.go | 11 ++- internal/apply/since_integration_test.go | 59 +++++++++++++++ 4 files changed, 182 insertions(+), 2 deletions(-) diff --git a/internal/apply/dryrun.go b/internal/apply/dryrun.go index 310d02cc..b4e914b3 100644 --- a/internal/apply/dryrun.go +++ b/internal/apply/dryrun.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "sort" + "strings" dash0yaml "github.com/dash0hq/dash0-api-client-go/yaml" "github.com/dash0hq/dash0-cli/internal/agentmode" @@ -82,7 +83,7 @@ func buildDryRunRows(documents []assetDocument, dp *deletionPlan) (rowsByFile ma name = "" } basePath, _ := splitMultiDocPath(d.Path) - addRow(basePath, dryRunRow{op: "delete", kind: d.Kind, name: name, originOrID: d.Identifier}) + addRow(stripScope(basePath, dp.scope), dryRunRow{op: "delete", kind: d.Kind, name: name, originOrID: d.Identifier}) } for _, a := range dp.plan.AlertsByName { addRow(crdFileByIdentifier[a.CRDIdentifier], dryRunRow{ @@ -167,6 +168,25 @@ func renderDryRunLine(r dryRunRow) string { return fmt.Sprintf("%s %s %s", verb, asset.KindDisplayName(r.kind), formatNameAndId(r.name, r.originOrID)) } +// stripScope removes scope's directory prefix from path, converting a +// repo-root-relative deletion path (gitutil.Deletion.Path, as read via git +// ls-tree) into the same -f-target-relative basis validated documents' +// assetDocument.filePath already uses -- otherwise a deletion from a +// subdirectory -f target groups under a different (repo-root-relative) key +// than that same file's surviving documents, splitting one file into two +// entries with inconsistent prefixing. scope is "" when the target is the +// repository root itself, in which case the two bases already coincide. +func stripScope(path, scope string) string { + if scope == "" { + return path + } + prefix := scope + "/" + if rest, ok := strings.CutPrefix(path, prefix); ok { + return rest + } + return path +} + // fileSuffix renders the " from N files" clause the --since summary line // adds only when the target was a directory — a single-file or stdin target // has no file count worth stating. diff --git a/internal/apply/dryrun_test.go b/internal/apply/dryrun_test.go index 704bb1da..2ee1b107 100644 --- a/internal/apply/dryrun_test.go +++ b/internal/apply/dryrun_test.go @@ -12,6 +12,30 @@ import ( "github.com/stretchr/testify/require" ) +// TestStripScope is a table test for the deletion-path/validated-document +// path basis mismatch under a subdirectory -f target: gitutil.Deletion.Path +// is repo-root-relative, assetDocument.filePath is -f-target-relative, and +// scope (the -f target's own repo-root-relative path) is what bridges them. +func TestStripScope(t *testing.T) { + cases := []struct { + path string + scope string + want string + }{ + {"keep.yaml", "", "keep.yaml"}, + {"dashboards/keep.yaml", "dashboards", "keep.yaml"}, + {"dashboards/nested/keep.yaml", "dashboards", "nested/keep.yaml"}, + // No prefix match: leave the path untouched rather than guessing. + {"other/keep.yaml", "dashboards", "other/keep.yaml"}, + // A directory name that merely starts with scope's name, without the + // separator, must not be treated as a match. + {"dashboards2/keep.yaml", "dashboards", "dashboards2/keep.yaml"}, + } + for _, c := range cases { + assert.Equal(t, c.want, stripScope(c.path, c.scope), "path %q scope %q", c.path, c.scope) + } +} + func withAgentMode(t *testing.T, enabled bool) { t.Helper() prev := agentmode.Enabled @@ -73,6 +97,74 @@ func TestRunDryRun_JSON_MergedWithDeletions(t *testing.T) { assert.Equal(t, dryRunChangeJSON{Op: "delete", Name: "High Error Rate", OriginOrID: "44444444-4444-4444-4444-444444444444"}, out[0].Changes[1]) } +// TestRunDryRun_JSON_MergedWithDeletions_SubdirectoryScope is a regression +// test for a bug where a -f target that is a subdirectory of the repo (not +// the repo root) grouped a file's surviving and deleted assets under two +// different keys: gitutil.Deletion.Path is always repo-root-relative (from +// git ls-tree), while assetDocument.filePath is always relative to the -f +// target itself -- so "dashboards/removed.yaml" (deletion) and +// "removed.yaml" (had it survived) would never merge, and the deletion +// would render as a separate, inconsistently-prefixed file entry instead of +// joining its file's other row. dp.scope must be stripped from the +// deletion's path before grouping. +func TestRunDryRun_JSON_MergedWithDeletions_SubdirectoryScope(t *testing.T) { + withAgentMode(t, true) + + documents := []assetDocument{ + {kind: "dashboard", name: "Kept Dashboard", id: "11111111-1111-1111-1111-111111111111", filePath: "keep.yaml"}, + } + dp := &deletionPlan{ + plan: gitutil.DeletionPlan{ + ByIdentifier: []gitutil.Deletion{ + {Kind: "dashboard", Identifier: "22222222-2222-2222-2222-222222222222", Path: "dashboards/removed.yaml"}, + }, + }, + names: map[string]string{"dashboards/removed.yaml": "Old Dashboard"}, + scope: "dashboards", + } + + stdout := testutil.CaptureStdout(t, func() { + require.NoError(t, runDryRun(documents, true, "dashboards", "abc123", dp)) + }) + + var out []dryRunFileJSON + require.NoError(t, json.Unmarshal([]byte(stdout), &out)) + require.Len(t, out, 2) + paths := []string{out[0].Path, out[1].Path} + assert.ElementsMatch(t, []string{"keep.yaml", "removed.yaml"}, paths, "deletion path must have dp.scope stripped, matching validated documents' basis") +} + +// TestRunDryRun_JSON_MergedWithDeletions_SameFileSubdirectoryScope covers +// the actual merge case under a subdirectory -f target: a multi-document +// file with one surviving and one deleted asset must still land in a single +// {path, changes} entry, not two, once dp.scope is accounted for. +func TestRunDryRun_JSON_MergedWithDeletions_SameFileSubdirectoryScope(t *testing.T) { + withAgentMode(t, true) + + documents := []assetDocument{ + {kind: "view", name: "error-logs-view", id: "33333333-3333-3333-3333-333333333333", filePath: "assets.yaml"}, + } + dp := &deletionPlan{ + plan: gitutil.DeletionPlan{ + ByIdentifier: []gitutil.Deletion{ + {Kind: "checkrule", Identifier: "44444444-4444-4444-4444-444444444444", Path: "dashboards/assets.yaml#1"}, + }, + }, + names: map[string]string{"dashboards/assets.yaml#1": "High Error Rate"}, + scope: "dashboards", + } + + stdout := testutil.CaptureStdout(t, func() { + require.NoError(t, runDryRun(documents, true, "dashboards", "abc123", dp)) + }) + + var out []dryRunFileJSON + require.NoError(t, json.Unmarshal([]byte(stdout), &out)) + require.Len(t, out, 1, "the surviving and deleted assets from the same file must merge into one entry") + assert.Equal(t, "assets.yaml", out[0].Path) + require.Len(t, out[0].Changes, 2) +} + // TestRunDryRun_JSON_SingleFileTarget pins that a single-file (non-directory) // -f target reports one file entry keyed by the literal -f argument, since // there is no real per-document file grouping to report. diff --git a/internal/apply/since.go b/internal/apply/since.go index 4abe9e97..d11d0eed 100644 --- a/internal/apply/since.go +++ b/internal/apply/since.go @@ -30,6 +30,15 @@ type deletionPlan struct { // (deleteAssetByKindAndIdentifier) never depends on it, only on // (kind, identifier). names map[string]string + // scope is the --since target's path relative to the repository root + // (forward-slashed, "" when the target is the repo root itself). Deletion + // paths (gitutil.Deletion.Path, from git ls-tree) are always repo-root- + // relative, while validated documents' assetDocument.filePath is always + // relative to the -f target itself -- when the target is a subdirectory, + // those two bases differ, so rendering must strip this prefix from a + // deletion path before grouping it with validated documents from the same + // file. See stripScope in dryrun.go. + scope string } // computeDeletionPlan resolves flags.Since against the git repository @@ -126,7 +135,7 @@ func computeDeletionPlan(ctx context.Context, flags *applyFlags) (*deletionPlan, names := resolveDeletionNames(ctx, repo, sha, plan.ByIdentifier) - return &deletionPlan{plan: plan, warning: warning, names: names}, nil + return &deletionPlan{plan: plan, warning: warning, names: names, scope: scope}, nil } // resolveDeletionNames best-effort looks up each deletion's display name by diff --git a/internal/apply/since_integration_test.go b/internal/apply/since_integration_test.go index 17d11f62..ceb39261 100644 --- a/internal/apply/since_integration_test.go +++ b/internal/apply/since_integration_test.go @@ -829,6 +829,65 @@ spec: assert.Contains(t, output, "Delete Dashboard") } +// TestApply_Since_DryRunPreview_SubdirectoryScopeGroupsConsistently is a +// regression test for a bug where a -f target that is a subdirectory of the +// repo (not the repo root) rendered a deletion under its repo-root-relative +// path while validated documents rendered under their -f-target-relative +// path -- producing inconsistent, mismatched file-path prefixing in the same +// listing (e.g. "keep.yaml" next to "dashboards/removed.yaml") instead of +// both files rendering on the same (-f-target-relative) basis. +func TestApply_Since_DryRunPreview_SubdirectoryScopeGroupsConsistently(t *testing.T) { + testutil.SetupTestEnv(t) + + repoRoot := t.TempDir() + runGitCmd(t, repoRoot, "init", "-q", "-b", "main") + runGitCmd(t, repoRoot, "config", "user.email", "test@example.com") + runGitCmd(t, repoRoot, "config", "user.name", "Test") + runGitCmd(t, repoRoot, "config", "commit.gpgsign", "false") + + writeFileFixture(t, repoRoot, "dashboards/keep.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: keep-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: Keep Dashboard +`) + writeFileFixture(t, repoRoot, "dashboards/removed.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: removed-dashboard + dash0Extensions: + id: b2c3d4e5-6789-01bc-def0-234567890abc +spec: + display: + name: Removed Dashboard +`) + runGitCmd(t, repoRoot, "add", "-A") + runGitCmd(t, repoRoot, "commit", "-q", "-m", "add dashboards") + before := strings.TrimSpace(runGitCmd(t, repoRoot, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(repoRoot, "dashboards", "removed.yaml"))) + runGitCmd(t, repoRoot, "add", "-A") + runGitCmd(t, repoRoot, "commit", "-q", "-m", "remove dashboard") + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{"-f", filepath.Join(repoRoot, "dashboards"), "--since", before, "--dry-run", "--experimental"}) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.Contains(t, output, " keep.yaml\n") + assert.Contains(t, output, " removed.yaml\n") + assert.NotContains(t, output, "dashboards/keep.yaml") + assert.NotContains(t, output, "dashboards/removed.yaml") +} + // TestApply_Since_SpamFilterIDOnlyDeletionWarns is a regression test for a // gap where deleting a spam filter identified by dash0.com/id alone gave no // indication that the id recorded in git history might no longer match the From 70600be43a38b1545875ddba98667b3d29e49235 Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 17 Aug 2026 15:53:03 +0200 Subject: [PATCH 12/42] docs(apply): document --since/--force apply --since/--force shipped in this branch with no documentation at all beyond an incidental spam-filter note. Adds: - docs/commands.md: --since/--force in the apply flags table and usage line, a dedicated `apply --since` (experimental) subsection covering identity/deletion semantics, the merged --dry-run preview (including its agent-mode JSON shape), per-asset confirmation and --force, the non-zero exit on a declined deletion, the ref-resolution error messages (empty/all-zeros/unresolvable), the non-ancestor warning, the no-identifier hard-fail, PrometheusRule alerting-rule deletion, the git/Docker requirement, and a GitHub Actions invocation example; plus a "Common workflows" entry and a taxonomy-intro mention. - README.md: a --since example in "Applying assets", cross-referencing the full reference. - docs/installation.md + README.md: a note that the Docker image (built FROM scratch) has no git, so --since is unavailable from it. - docs/promoting-commands-to-stable.md: a new "Flag-level promotion" section (apply --since is the first flag-level experimental gate in this CLI, so the existing whole-command guide didn't cover it). - internal/skill/gen bundle regenerated (make skill-bundle) to pick up the docs/commands.md changes; SKILL.md's apply summary and workflow list hand-updated to match. Also fixes a pre-existing inaccuracy noticed while writing these examples: apply's per-file output prefixes each line with the file's path relative to -f's own target, not the invoking shell's directory, so `dash0 apply -f assets/` prints "dashboard.yaml: ...", not "assets/dashboard.yaml: ...". Marks tasks.md's Section 8 items done for the --since/--force scope; the diff-specific portions of 8.1/8.2/8.3/8.6 and all of 8.8 remain open until dash0 diff (Section 5) exists. --- README.md | 12 ++ docs/commands.md | 126 +++++++++++++++++- docs/installation.md | 4 + docs/promoting-commands-to-stable.md | 13 ++ internal/skill/content/SKILL.md | 12 +- internal/skill/content/references/apply.md | 111 ++++++++++++++- .../changes/add-diff-and-since-flag/tasks.md | 14 +- 7 files changed, 273 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 5f2b9dfe..c6a8c3b7 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,10 @@ docker run ghcr.io/dash0hq/cli:latest [command] Multi-architecture images (`linux/amd64`, `linux/arm64`) are published to GitHub Container Registry. +> [!NOTE] +> This image is built `FROM scratch` and has no shell or other tools installed, including `git`. +> Commands that shell out to `git` (currently `apply --since`) are unavailable from it. + ### Nix / NixOS The repository is a Nix flake that builds the CLI with `buildGoModule` and installs shell completions for Bash, Zsh, and Fish. @@ -373,6 +377,14 @@ Validate without applying: dash0 apply -f assets.yaml --dry-run ``` +Sync a directory to match its state as of a git ref, deleting assets removed since then (experimental, requires `-X`): + +```bash +dash0 -X apply -f dashboards/ --since HEAD~1 --force +``` + +See [Command Reference](docs/commands.md#apply---since-experimental) for the full `--since` reference, including the ref-resolution edge cases and the GitHub Actions invocation pattern. + **Note:** In Dash0, dashboards, views, synthetic checks and check rules are called "assets", rather than the more common "resources". The reason for this is that the word "resource" is overloaded in OpenTelemetry, where it describes "where telemetry comes from". diff --git a/docs/commands.md b/docs/commands.md index 625a11c0..3cad46e7 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -28,7 +28,7 @@ A profile can be in one of three auth states: **static** (holds a long-lived `au **Asset CRUD commands** create, list, get, update, and delete dataset-scoped assets (dashboards, views, check rules, synthetic checks, recording rules). They use file-based input (`-f`), support `--dry-run`, and offer five output formats (`table`, `wide`, `json`, `yaml`, `csv`). -The `apply` command provides create-or-update semantics across all asset types. +The `apply` command provides create-or-update semantics across all asset types, and (experimentally, via `--since`) delete semantics based on git history. **Query commands** search and retrieve telemetry signals. They accept time range flags (`--from`, `--to`), a repeatable `--filter` flag with the standard [filter syntax](#filter-syntax), and customizable columns via `--column`. @@ -871,13 +871,15 @@ Apply asset definitions from a file, directory, or stdin. If an asset already exists (matched by ID), it is updated; otherwise it is created. ```bash -dash0 apply -f [--dry-run] +dash0 apply -f [--dry-run] [--since [--force]] ``` | Flag | Short | Description | |------|-------|-------------| | `--file` | `-f` | Path to a YAML/JSON file, a directory, or `-` for stdin | | `--dry-run` | | Validate without applying | +| `--since` | | [experimental] Delete assets removed from `-f`'s contents since this git ref (requires `--experimental`/`-X`) | +| `--force` | | Skip the confirmation prompt for deletions triggered by `--since` | For assets that are updated, a unified diff of the changes is shown. Assets that are created show the standard creation message. @@ -925,12 +927,13 @@ $ dash0 apply -f dashboard.yaml Dashboard "Production Overview" (a1b2c3d4-...) created ``` -Apply a directory recursively: +Apply a directory recursively. +Each line is prefixed with the file's path relative to `-f`'s own target, not the shell's current directory — a nested `assets/dashboards/dashboard.yaml` would print as `dashboards/dashboard.yaml` here: ```bash $ dash0 apply -f assets/ -assets/dashboard.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created -assets/rule.yaml: Check rule "High Error Rate" (b2c3d4e5-...) updated +dashboard.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created +rule.yaml: Check rule "High Error Rate" (b2c3d4e5-...) updated ... ``` @@ -947,9 +950,109 @@ Dry-run validation: ```bash $ dash0 apply -f assets.yaml --dry-run Dry run: 1 document validated - 1. Dashboard "Production Overview" (a1b2c3d4-5678-90ab-cdef-1234567890ab) + * Apply Dashboard "Production Overview" (a1b2c3d4-5678-90ab-cdef-1234567890ab) ``` +#### `apply --since` (experimental) + +`--since ` turns `apply` into a full GitOps sync, not just create/update: it additionally deletes any asset whose definition existed in `-f`'s scanned scope at `` but is no longer present in the current contents. +`` accepts any revision expression `git` itself accepts — a commit SHA, a branch or tag name, or a relative expression like `HEAD~1` — and `-f`'s target must be inside a git repository (a single file or a directory both work). + +Deletion detection is by identifier — the asset's `dash0.com/id` or `dash0.com/origin`, per the [asset identifiers table](#asset-identifiers-and-idempotent-upsert) — never by file path, so moving or renaming a file within the scanned scope is not a deletion. +`--since` requires `--experimental`/`-X`. +`apply` itself is not gated: every other flag, including `--dry-run`, is completely unaffected when `--since` is not passed. + +Preview a `--since` deletion without applying anything. +The deletion is merged into the same per-file `--dry-run` listing used for creates and updates, sorted by identifier within each file: + +```bash +$ dash0 --experimental apply -f dashboards/ --since HEAD~1 --dry-run +Dry run: 1 document from 1 file validated; 1 deletion pending due to --since 'HEAD~1' + keep.yaml + * Apply Dashboard "Production Overview" (a1b2c3d4-5678-90ab-cdef-1234567890ab) + removed.yaml + * Delete Dashboard "Old Dashboard" (b2c3d4e5-6789-01bc-def0-234567890abc) +``` + +A deleted asset's name is resolved by reading its content from git history at ``; if that lookup fails, `` is shown as a placeholder instead. +In agent mode, `--dry-run` reports the same information as JSON: an array of `{path, changes: [{op, name, originOrId}]}`, `op` being `"apply"` or `"delete"`. + +Apply for real, deleting assets removed since ``. +Each deletion prompts for confirmation, the same as a standalone ` delete`: + +```bash +$ dash0 --experimental apply -f dashboards/ --since HEAD~1 +keep.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created +Are you sure you want to delete Dashboard "Old Dashboard" (b2c3d4e5-...), removed since --since ref? [y/N]: y +Dashboard "Old Dashboard" (b2c3d4e5-...) deleted +``` + +Skip the confirmation prompt (for CI/CD and agent-driven pipelines, where there is no terminal to answer it): + +```bash +dash0 --experimental apply -f dashboards/ --since HEAD~1 --force +``` + +Declining a deletion does not stop the rest of the run — creates and updates for the surviving documents still go through — but the command exits non-zero, since the sync's desired end state ("this asset is gone, matching git") was not reached: + +```bash +$ dash0 --experimental apply -f dashboards/ --since HEAD~1 +keep.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created +Are you sure you want to delete Dashboard "Old Dashboard" (b2c3d4e5-...), removed since --since ref? [y/N]: n +Dashboard "Old Dashboard" (b2c3d4e5-...): deletion declined +$ echo $? +1 +``` + +Two `--since` values get a dedicated, CI-agnostic error message instead of a generic git-resolution failure, since both are common results of imperfect GitHub Actions wiring rather than a typo'd ref: + +- The empty string (`--since ""`) — the value a quoted `--since "${{ github.event.before }}"` interpolates to on trigger types that don't define `before` (e.g. `workflow_dispatch`, `schedule`). +- Git's all-zeros SHA sentinel (`0000000000000000000000000000000000000000`) — the value GitHub gives `github.event.before` on a branch's first push. + +Both errors recommend skipping `--since` for that invocation, or passing an explicit ref. +Any other unresolvable ref (a typo, a too-shallow clone) surfaces the plain git error instead. + +A `--since` ref that resolves to a real commit but is not an ancestor of the current commit — the result of a force-push or history rewrite on the tracked branch — prints a warning naming the likely cause, then goes through the same per-asset confirmation as any other deletion. +It does not hard-fail, so a legitimate force-push still has a recovery path: + +```bash +$ dash0 --experimental apply -f dashboards/ --since --force +warning: --since '' is not an ancestor of HEAD (likely a force-push or history rewrite); deletion detection may be inaccurate +keep.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created +Dashboard "Old Dashboard" (b2c3d4e5-...) deleted +``` + +A document removed from git history with no `dash0.com/id` or `dash0.com/origin` at `` fails the entire `--since` run before creating, updating, or deleting anything, since there is no reliable way to know which live asset (if any) it corresponds to: + +```bash +$ dash0 --experimental apply -f dashboards/ --since HEAD~1 +Error: --since 'HEAD~1' found 1 document deleted with no dash0.com/id or dash0.com/origin label, so deletion cannot be determined reliably: + removed.yaml +``` + +For a `PrometheusRule` CRD, identity is CRD-level: removing one alerting rule while others remain in the same CRD is detected too, resolved by its composed check-rule name (` - `) rather than by the CRD's shared identifier, since there is no per-alert id to delete by: + +```bash +$ dash0 --experimental apply -f rules/ --since HEAD~1 --force +alerts.yaml: PrometheusRule "service-alerts" (c3d4e5f6-...) created +Check rule "service-alerts - HighLatency" deleted +``` + +`--since` needs a real `git` binary on `PATH`. +It is unavailable from the `ghcr.io/dash0hq/cli` Docker image, which is built `FROM scratch` and has no shell or other tools installed. + +##### Using `--since` from a GitHub Actions workflow + +Quote the interpolated ref and gate the whole step on the event actually providing a usable value, so an unquoted expansion or an undefined `before` never reaches `dash0` as an ambiguous or wrong ref: + +```yaml +- name: Sync deletions since the last push + if: github.event.before != '0000000000000000000000000000000000000000' + run: dash0 --experimental apply -f dashboards/ --since "${{ github.event.before }}" --force +``` + +`fetch-depth: 0` (or a depth covering `github.event.before`) is required on the preceding `actions/checkout` step — a shallow clone makes `` unresolvable, which `--since` treats as a plain error, not a fallback. + ### Asset YAML formats Dashboard: @@ -2988,3 +3091,14 @@ Use `--dry-run` to check for errors without making changes: ```bash dash0 apply -f assets/ --dry-run ``` + +### Sync a directory to match its state as of a git ref (experimental) + +`apply --since ` deletes assets removed from `-f`'s contents since ``, in addition to the usual create/update behavior. +Requires `--experimental`/`-X` and `-f`'s target to be inside a git repository: + +```bash +dash0 --experimental apply -f assets/ --since HEAD~1 --force +``` + +Preview the plan (creates, updates, and deletions) first with `--dry-run`; see [`apply --since`](#apply---since-experimental) for the full reference, including the GitHub Actions invocation pattern and the ref-resolution edge cases. diff --git a/docs/installation.md b/docs/installation.md index 4cd7427f..46bd3887 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -26,6 +26,10 @@ docker run ghcr.io/dash0hq/cli:latest [command] Multi-architecture images (`linux/amd64`, `linux/arm64`) are published to the GitHub Container Registry. +> [!NOTE] +> This image is built `FROM scratch` and has no shell or other tools installed, including `git`. +> Commands that shell out to `git` (currently `apply --since`) are unavailable from it. + ## Nix / NixOS The repository is published as a Nix flake. diff --git a/docs/promoting-commands-to-stable.md b/docs/promoting-commands-to-stable.md index 6d78cb0d..5649407f 100644 --- a/docs/promoting-commands-to-stable.md +++ b/docs/promoting-commands-to-stable.md @@ -89,3 +89,16 @@ Validate with `make chlog-validate`. 3. `make lint` passes. 4. `./dash0 --help` shows help without `[experimental]` prefix. 5. `./dash0 -X --help` still works (backward compatibility). + +## Flag-level promotion (e.g. `apply --since`) + +Everything above assumes the whole command is gated (`experimental.RequireExperimental`). +A command can instead gate a single flag on an otherwise-stable command — `apply --since` is the first example, using `experimental.RequireExperimentalFlag(cmd, flagName)` instead of `RequireExperimental(cmd)`, because `apply` itself is stable and heavily used; gating the whole command to protect one new flag would force every existing caller to add `-X` for no reason (see `openspec/changes/add-diff-and-since-flag/design.md`'s rationale). +Promoting a flag-level gate follows the same shape as above, with these differences: + +1. **Remove the gate call**: delete the `experimental.RequireExperimentalFlag(cmd, "")` call from `RunE`, not `RequireExperimental`. The command's own `Short`/`Long` text is not `[experimental]`-prefixed to begin with (only the flag's own description names the requirement), so there is no prefix to remove. +2. **Trim examples and flag description, not the whole command**: drop `-X`/`--experimental` from only the `Example` lines that exercise the promoted flag — lines demonstrating the command's other, already-stable behavior are unaffected. Drop the `requires --experimental/-X` (or equivalent) clause from just that flag's description in `cmd.Flags().___Var(...)`, not from the command's `Short`/`Long`. +3. **Backward-compat test shape**: name it `Test_BackwardCompatWithExperimentalFlag` (e.g. `TestApply_SinceBackwardCompatWithExperimentalFlag`) and assert the flag's behavior still succeeds with `-X` passed — the rest of the command's tests (exercising it without the flag) need no `-X` before or after promotion, since they were never gated. +4. **`docs/commands.md`**: remove the flag's own `[experimental]` marker and `requires --experimental/-X` clause (e.g. in its row of the command's flag table and its dedicated subsection heading, such as `#### \`apply --since\` (experimental)` → `#### \`apply --since\``), not a whole-command header. Drop `-X`/`--experimental` from the flag's own examples only. +5. **`README.md`**: no `[!WARNING]` block to remove for a flag-level gate (that pattern is for whole experimental commands); drop `-X` from the promoted flag's own example(s) only. +6. **Changelog**: `note` names the specific flag promoted (e.g. "`apply --since` no longer requires `--experimental`"), not the whole command. diff --git a/internal/skill/content/SKILL.md b/internal/skill/content/SKILL.md index a8d8d3ca..ef8bc1c7 100644 --- a/internal/skill/content/SKILL.md +++ b/internal/skill/content/SKILL.md @@ -41,7 +41,7 @@ Agent mode optimizes the CLI for AI agents: JSON output by default, structured ` All seven asset types (`dashboards`, `check-rules`, `synthetic-checks`, `views`, `recording-rules`, `notification-channels`, `spam-filters`) share the same five subcommands: `list`, `get`, `create` (alias `add`), `update`, `delete` (alias `remove`). Output formats are `table`, `wide`, `json`, `yaml`, `csv` (query commands use `table`/`json`/`csv` only). `create`/`update` accept `-f ` (or `-f -` for stdin) and `--dry-run`. -`dash0 apply -f ` provides create-or-update semantics across all asset types in one command — see the `apply` topic. +`dash0 apply -f ` provides create-or-update semantics across all asset types in one command, and (experimentally, via `--since `) delete semantics based on git history — see the `apply` topic. ### Asset identifiers and idempotent upsert @@ -141,6 +141,16 @@ With `--force`, an already-deleted asset is treated as idempotent success — th dash0 apply -f assets/ --dry-run ``` +### Sync a directory to match its state as of a git ref (experimental) + +`apply --since ` deletes assets removed from `-f`'s contents since ``, in addition to the usual create/update behavior. Requires `--experimental`/`-X` and `-f`'s target to be inside a git repository: + +```bash +dash0 --experimental apply -f assets/ --since HEAD~1 --force +``` + +Preview the plan first with `--dry-run` — see the `apply` topic for the full reference, including the GitHub Actions invocation pattern and ref-resolution edge cases. + ## Topics Run `dash0 skill show ` for the reference content below, or read `references/.md` directly if the skill is installed on disk. diff --git a/internal/skill/content/references/apply.md b/internal/skill/content/references/apply.md index 17c21645..7058c400 100644 --- a/internal/skill/content/references/apply.md +++ b/internal/skill/content/references/apply.md @@ -8,7 +8,7 @@ Apply asset definitions from a file, directory, or stdin. If an asset already exists (matched by ID), it is updated; otherwise it is created. ```bash -dash0 apply -f [--dry-run] +dash0 apply -f [--dry-run] [--since [--force]] ``` _For the exact, always-current flag list, run `dash0 --agent-mode apply --help`._ @@ -59,12 +59,13 @@ $ dash0 apply -f dashboard.yaml Dashboard "Production Overview" (a1b2c3d4-...) created ``` -Apply a directory recursively: +Apply a directory recursively. +Each line is prefixed with the file's path relative to `-f`'s own target, not the shell's current directory — a nested `assets/dashboards/dashboard.yaml` would print as `dashboards/dashboard.yaml` here: ```bash $ dash0 apply -f assets/ -assets/dashboard.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created -assets/rule.yaml: Check rule "High Error Rate" (b2c3d4e5-...) updated +dashboard.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created +rule.yaml: Check rule "High Error Rate" (b2c3d4e5-...) updated ... ``` @@ -81,9 +82,109 @@ Dry-run validation: ```bash $ dash0 apply -f assets.yaml --dry-run Dry run: 1 document validated - 1. Dashboard "Production Overview" (a1b2c3d4-5678-90ab-cdef-1234567890ab) + * Apply Dashboard "Production Overview" (a1b2c3d4-5678-90ab-cdef-1234567890ab) ``` +#### `apply --since` (experimental) + +`--since ` turns `apply` into a full GitOps sync, not just create/update: it additionally deletes any asset whose definition existed in `-f`'s scanned scope at `` but is no longer present in the current contents. +`` accepts any revision expression `git` itself accepts — a commit SHA, a branch or tag name, or a relative expression like `HEAD~1` — and `-f`'s target must be inside a git repository (a single file or a directory both work). + +Deletion detection is by identifier — the asset's `dash0.com/id` or `dash0.com/origin`, per the [asset identifiers table](#asset-identifiers-and-idempotent-upsert) — never by file path, so moving or renaming a file within the scanned scope is not a deletion. +`--since` requires `--experimental`/`-X`. +`apply` itself is not gated: every other flag, including `--dry-run`, is completely unaffected when `--since` is not passed. + +Preview a `--since` deletion without applying anything. +The deletion is merged into the same per-file `--dry-run` listing used for creates and updates, sorted by identifier within each file: + +```bash +$ dash0 --experimental apply -f dashboards/ --since HEAD~1 --dry-run +Dry run: 1 document from 1 file validated; 1 deletion pending due to --since 'HEAD~1' + keep.yaml + * Apply Dashboard "Production Overview" (a1b2c3d4-5678-90ab-cdef-1234567890ab) + removed.yaml + * Delete Dashboard "Old Dashboard" (b2c3d4e5-6789-01bc-def0-234567890abc) +``` + +A deleted asset's name is resolved by reading its content from git history at ``; if that lookup fails, `` is shown as a placeholder instead. +In agent mode, `--dry-run` reports the same information as JSON: an array of `{path, changes: [{op, name, originOrId}]}`, `op` being `"apply"` or `"delete"`. + +Apply for real, deleting assets removed since ``. +Each deletion prompts for confirmation, the same as a standalone ` delete`: + +```bash +$ dash0 --experimental apply -f dashboards/ --since HEAD~1 +keep.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created +Are you sure you want to delete Dashboard "Old Dashboard" (b2c3d4e5-...), removed since --since ref? [y/N]: y +Dashboard "Old Dashboard" (b2c3d4e5-...) deleted +``` + +Skip the confirmation prompt (for CI/CD and agent-driven pipelines, where there is no terminal to answer it): + +```bash +dash0 --experimental apply -f dashboards/ --since HEAD~1 --force +``` + +Declining a deletion does not stop the rest of the run — creates and updates for the surviving documents still go through — but the command exits non-zero, since the sync's desired end state ("this asset is gone, matching git") was not reached: + +```bash +$ dash0 --experimental apply -f dashboards/ --since HEAD~1 +keep.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created +Are you sure you want to delete Dashboard "Old Dashboard" (b2c3d4e5-...), removed since --since ref? [y/N]: n +Dashboard "Old Dashboard" (b2c3d4e5-...): deletion declined +$ echo $? +1 +``` + +Two `--since` values get a dedicated, CI-agnostic error message instead of a generic git-resolution failure, since both are common results of imperfect GitHub Actions wiring rather than a typo'd ref: + +- The empty string (`--since ""`) — the value a quoted `--since "${{ github.event.before }}"` interpolates to on trigger types that don't define `before` (e.g. `workflow_dispatch`, `schedule`). +- Git's all-zeros SHA sentinel (`0000000000000000000000000000000000000000`) — the value GitHub gives `github.event.before` on a branch's first push. + +Both errors recommend skipping `--since` for that invocation, or passing an explicit ref. +Any other unresolvable ref (a typo, a too-shallow clone) surfaces the plain git error instead. + +A `--since` ref that resolves to a real commit but is not an ancestor of the current commit — the result of a force-push or history rewrite on the tracked branch — prints a warning naming the likely cause, then goes through the same per-asset confirmation as any other deletion. +It does not hard-fail, so a legitimate force-push still has a recovery path: + +```bash +$ dash0 --experimental apply -f dashboards/ --since --force +warning: --since '' is not an ancestor of HEAD (likely a force-push or history rewrite); deletion detection may be inaccurate +keep.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created +Dashboard "Old Dashboard" (b2c3d4e5-...) deleted +``` + +A document removed from git history with no `dash0.com/id` or `dash0.com/origin` at `` fails the entire `--since` run before creating, updating, or deleting anything, since there is no reliable way to know which live asset (if any) it corresponds to: + +```bash +$ dash0 --experimental apply -f dashboards/ --since HEAD~1 +Error: --since 'HEAD~1' found 1 document deleted with no dash0.com/id or dash0.com/origin label, so deletion cannot be determined reliably: + removed.yaml +``` + +For a `PrometheusRule` CRD, identity is CRD-level: removing one alerting rule while others remain in the same CRD is detected too, resolved by its composed check-rule name (` - `) rather than by the CRD's shared identifier, since there is no per-alert id to delete by: + +```bash +$ dash0 --experimental apply -f rules/ --since HEAD~1 --force +alerts.yaml: PrometheusRule "service-alerts" (c3d4e5f6-...) created +Check rule "service-alerts - HighLatency" deleted +``` + +`--since` needs a real `git` binary on `PATH`. +It is unavailable from the `ghcr.io/dash0hq/cli` Docker image, which is built `FROM scratch` and has no shell or other tools installed. + +##### Using `--since` from a GitHub Actions workflow + +Quote the interpolated ref and gate the whole step on the event actually providing a usable value, so an unquoted expansion or an undefined `before` never reaches `dash0` as an ambiguous or wrong ref: + +```yaml +- name: Sync deletions since the last push + if: github.event.before != '0000000000000000000000000000000000000000' + run: dash0 --experimental apply -f dashboards/ --since "${{ github.event.before }}" --force +``` + +`fetch-depth: 0` (or a depth covering `github.event.before`) is required on the preceding `actions/checkout` step — a shallow clone makes `` unresolvable, which `--since` treats as a plain error, not a fallback. + ### PrometheusRule annotation merge A PrometheusRule document's top-level `metadata.annotations` are merged into each alerting rule's own annotations, key by key. A rule that sets the same key wins for that key only, and still inherits the rest. diff --git a/openspec/changes/add-diff-and-since-flag/tasks.md b/openspec/changes/add-diff-and-since-flag/tasks.md index 8add9ed3..f461f8cb 100644 --- a/openspec/changes/add-diff-and-since-flag/tasks.md +++ b/openspec/changes/add-diff-and-since-flag/tasks.md @@ -100,13 +100,13 @@ Everything above tests either in-process (unit tests share the Go test binary; i ## 8. Documentation -- [ ] 8.1 `docs/commands.md`: add a `diff` section under the appropriate taxonomy row (alongside `apply`, since it spans multiple asset kinds rather than being a single-kind CRUD command — update the taxonomy table if `diff` needs its own row) with the `[experimental]` framing and `-X` shown in every example, matching the convention used for `otlp proxy`/`teams`/etc.; update the `apply` section for `--since` (with its own `-X` requirement, distinct from the rest of the stable `apply` command), `--force`, the `--dry-run` deprecation note, and exit codes. -- [ ] 8.2 `README.md`: add `dash0 diff` to the command overview if the top-level command list changes (per `docs/documentation.md`'s README/`docs/about.md` sync rule if applicable), with the same experimental framing. -- [ ] 8.3 `internal/skill/gen`: add `diff` (and the `apply --since`/`--force` additions) to the topic map per `docs/agent-skill-maintenance.md`; run `make skill-bundle` and commit the regenerated `internal/skill/content/references/*.md`; update `SKILL.md`'s topic index. -- [ ] 8.4 Document the Docker distribution limitation (`ghcr.io/dash0hq/cli` has no `git`) in the relevant install/usage docs. -- [ ] 8.5 Document the correct, safe GitHub Actions `--since` invocation pattern (quoting, `if:` gating, and the current `-X` requirement) for users not using `asset-synch` — cross-reference `openspec/changes/add-asset-synch-action` for the convenience-action alternative. -- [ ] 8.6 Changelog entries (`make chlog-new`) for: the new `diff` command, `apply --since`/`--force`, and the `apply --dry-run` deprecation — all noting the `-X` requirement where applicable. -- [ ] 8.7 Extend `docs/promoting-commands-to-stable.md` with the flag-level promotion case (this doc currently only covers whole-command promotion): removing a `RequireExperimentalFlag` call instead of `RequireExperimental`, dropping `-X` from just the affected flag's examples rather than the whole command's, and the equivalent backward-compat test shape (7.7). This is a project-convention update, not just documentation for this one feature — the next feature needing a flag-level gate will look here. +- [x] 8.1 `docs/commands.md`: updated the `apply` section for `--since`/`--force` (flags table, a dedicated `#### \`apply --since\` (experimental)` subsection covering identity/deletion semantics, the merged `--dry-run` preview and its agent-mode JSON shape, confirmation/`--force`, exit code on decline, ref-resolution errors, the non-ancestor warning, the no-identifier hard-fail, PrometheusRule alert-level deletion, the Docker/git limitation, and a GitHub Actions invocation example), plus a "Common workflows for AI agents" entry and a one-line mention in the Asset CRUD taxonomy intro. **Deviation:** the `diff` section and the `--dry-run` deprecation note are not part of this task — `dash0 diff` (Section 5) doesn't exist yet and the deprecation note is postponed until it does (see 4.11's note); revisit both when Section 5 ships. +- [x] 8.2 `README.md`: added a `--since` example + cross-reference to the "Applying assets" section. **Deviation:** no `dash0 diff` entry — not built yet. +- [x] 8.3 Regenerated `internal/skill/content/references/apply.md` via `make skill-bundle` (the `apply` topic's `sections: []string{"apply"}` spec already captures the new subsection automatically) and hand-updated `SKILL.md`'s `apply` one-liner and workflow list to match. `make skill-validate` passes. **Deviation:** no `diff` topic added yet — not built. +- [x] 8.4 Documented the Docker distribution limitation (no `git` in the `FROM scratch` `ghcr.io/dash0hq/cli` image) inline in `docs/commands.md`'s `apply --since` subsection, and as a `[!NOTE]` in both `README.md`'s and `docs/installation.md`'s Docker sections (kept mirrored per `CLAUDE.md`'s sync rule). +- [x] 8.5 Documented the safe GitHub Actions `--since` invocation pattern (quoted interpolation, `if:` gating on the all-zeros sentinel, `fetch-depth: 0`) in `docs/commands.md`'s `apply --since` subsection and `SKILL.md`'s workflow list. Cross-reference to `openspec/changes/add-asset-synch-action` was deliberately omitted from the public-facing docs since that action isn't built yet — an internal proposal path isn't useful to an external reader. +- [x] 8.6 Changelog entry already covers `apply --since`/`--force` (`.chloggen/feat_sync-action.yaml`, referencing #253) including the merged dry-run preview and agent-mode JSON output added afterward. **Deviation:** no entry for `dash0 diff` or the `--dry-run` deprecation yet — neither exists. +- [x] 8.7 Extended `docs/promoting-commands-to-stable.md` with a "Flag-level promotion" section covering `apply --since` as the worked example: removing a `RequireExperimentalFlag` call instead of `RequireExperimental`, trimming only the affected flag's examples/description, the `Test_BackwardCompatWithExperimentalFlag` test-naming convention, and the corresponding `docs/commands.md`/`README.md`/changelog scoping differences from whole-command promotion. - [ ] 8.8 Document the recommended CI invocation pattern for `dash0 diff`'s exit code (`0` clean / `1` differences pending / `2` error) — e.g. explicit branching on the exit code or `continue-on-error`-style guidance — so a naive CI step doesn't fail on the routine "changes pending" case, the same footgun `kubectl diff` users commonly hit. ## 9. Verification From eb28d79708f8948c6887a9544a1d9beb0537fe61 Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 24 Aug 2026 14:50:18 +0200 Subject: [PATCH 13/42] fix(apply): support --since when every asset under -f's target was deleted apply --since failed outright once every asset definition under -f's target was removed: an empty (but surviving) directory hit readDirectory's "no .yaml or .yml files found" error, and a fully removed target directory failed even earlier at os.Stat, both before computeDeletionPlan ever ran. Both cases are now treated as a legitimate all-deletions run, reporting every asset found at --since's ref as a deletion instead of erroring. Also adds regression coverage (and a shared git-scenario fixture) for renaming a subdirectory within the scanned scope, which must remain a no-op since deletion detection is by identifier, never by path -- plus the counterpart case where the -f target itself is renamed and left pointed at the old path, which is correctly reported as a deletion. --- docs/commands.md | 15 ++ docs/testing.md | 2 + internal/apply/apply.go | 41 +++- internal/apply/apply_test.go | 14 ++ internal/apply/since.go | 62 +++++- internal/apply/since_integration_test.go | 126 ++++++++++++ internal/apply/since_test.go | 192 ++++++++++++++++++ internal/git/snapshot.go | 11 + internal/git/snapshot_test.go | 16 ++ internal/skill/content/references/apply.md | 15 ++ .../git-scenarios/directory-rename.yml | 45 ++++ .../whole-directory-deletion.yml | 40 ++++ internal/testutil/gitscenario_test.go | 32 +++ test/e2e/since_e2e_test.go | 63 ++++++ 14 files changed, 658 insertions(+), 16 deletions(-) create mode 100644 internal/testutil/fixtures/git-scenarios/directory-rename.yml create mode 100644 internal/testutil/fixtures/git-scenarios/whole-directory-deletion.yml diff --git a/docs/commands.md b/docs/commands.md index 3cad46e7..9beac612 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1038,6 +1038,21 @@ alerts.yaml: PrometheusRule "service-alerts" (c3d4e5f6-...) created Check rule "service-alerts - HighLatency" deleted ``` +When every asset definition under `-f`'s target has been deleted, `--since` still detects and reports every one of them, rather than failing outright. +This holds whether the target directory survives (now empty of `.yaml`/`.yml` files) or was removed entirely along with its files (e.g. `rm -rf dashboards/`) — both count as "nothing currently there," so every asset found at `` becomes a deletion candidate: + +```bash +$ dash0 --experimental apply -f dashboards/ --since HEAD~1 --dry-run +Dry run: 0 documents from 0 files validated; 2 deletions pending due to --since 'HEAD~1' + dashboard-a.yaml + * Delete Dashboard "Dashboard A" (dash-a) + view-b.yaml + * Delete View "View B" (view-b) +``` + +Without `--since`, an empty or missing `-f` target is a plain usage error instead — `apply` on its own has nothing to fall back to when there is nothing to apply, so it fails fast rather than silently doing nothing. +`--since` changes this because "everything under this target was deleted" is itself the meaningful, actionable outcome it exists to detect. + `--since` needs a real `git` binary on `PATH`. It is unavailable from the `ghcr.io/dash0hq/cli` Docker image, which is built `FROM scratch` and has no shell or other tools installed. diff --git a/docs/testing.md b/docs/testing.md index 87fc2880..593313d3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -109,6 +109,8 @@ The e2e harness does the same (`git config --global --add safe.directory '*'` in ## Scenarios - `whole-file-deletion` — a file is removed entirely between the ref and HEAD. +- `whole-directory-deletion` — every file under the ref is removed between the ref and HEAD, leaving the `-f` target (the repo root, in this scenario) with zero eligible YAML files. +- `directory-rename` — a file moves from one subdirectory to another between the ref and HEAD; deletion detection is by identifier, never by file path, so this must be a plain update, not a deletion. - `multi-document-partial-deletion` — one document is removed from a multi-document YAML file; the file survives. - `prometheus-alert-partial-deletion` — one alerting rule is removed from a `PrometheusRule` CRD; the CRD (and its shared `dash0.com/id`) survives. - `prometheus-recording-partial-removal` — the same shape for a recording rule; the correct behavior is a plain update, not a deletion (there is no per-record identity to diff). diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 84bbfdba..020f484a 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -3,6 +3,7 @@ package apply import ( "bytes" "context" + "errors" "fmt" "io" "os" @@ -176,16 +177,32 @@ func runApply(ctx context.Context, flags *applyFlags) error { } } else { info, statErr := os.Stat(flags.File) - if statErr != nil { + switch { + case statErr != nil && flags.SinceFlagSet && os.IsNotExist(statErr): + // --since's target no longer exists on disk at all: every asset + // definition under it was deleted, and (for a directory target) + // the directory itself was removed along with them. This is a + // legitimate all-deletions run, the same as the existing-but- + // empty-directory case below -- continue with zero current + // documents and let computeDeletionPlan report every asset found + // at the --since ref as a deletion. + fromDirectory = true + case statErr != nil: return fmt.Errorf("failed to read input: %w", statErr) - } - if info.IsDir() { + case info.IsDir(): fromDirectory = true documents, err = readDirectory(flags.File) if err != nil { - return validationError(err.Error()) + if flags.SinceFlagSet && errors.Is(err, errNoYAMLFilesFound) { + // Every asset definition that used to live in this + // directory was deleted, but the (now-empty) directory + // itself survives. Same all-deletions case as above. + documents = nil + } else { + return validationError(err.Error()) + } } - } else { + default: documents, err = readMultiDocumentYAML(flags.File, nil) if err != nil { return validationError(err.Error()) @@ -193,7 +210,7 @@ func runApply(ctx context.Context, flags *applyFlags) error { } } - if len(documents) == 0 { + if len(documents) == 0 && !flags.SinceFlagSet { return validationError("no documents found in input") } @@ -590,6 +607,14 @@ func parseMultiDocumentYAML(data []byte) ([]assetDocument, error) { return documents, nil } +// errNoYAMLFilesFound is wrapped into discoverFiles' "no .yaml or .yml files +// found" error so callers can distinguish "the directory is legitimately +// empty" from any other failure via errors.Is, without matching on message +// text. runApply uses this to tolerate an empty directory specifically when +// --since is set: every asset that used to live there may simply have been +// deleted, which is a valid all-deletions run, not a usage error. +var errNoYAMLFilesFound = errors.New("no .yaml or .yml files found") + // discoverFiles recursively finds all .yaml/.yml files under dirPath, // skipping hidden entries (names starting with '.'). // Returns paths relative to dirPath, sorted lexicographically. @@ -610,9 +635,9 @@ func discoverFiles(dirPath string) ([]string, error) { } if len(files) == 0 { if hasNestedDirs { - return nil, fmt.Errorf("no .yaml or .yml files found in %s and nested directories", dirPath) + return nil, fmt.Errorf("%w in %s and nested directories", errNoYAMLFilesFound, dirPath) } - return nil, fmt.Errorf("no .yaml or .yml files found in %s", dirPath) + return nil, fmt.Errorf("%w in %s", errNoYAMLFilesFound, dirPath) } sort.Strings(files) return files, nil diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go index 4f85d385..62471027 100644 --- a/internal/apply/apply_test.go +++ b/internal/apply/apply_test.go @@ -390,6 +390,20 @@ func TestDiscoverFiles_EmptyDir(t *testing.T) { assert.Contains(t, err.Error(), "no .yaml or .yml files found") } +// TestDiscoverFiles_EmptyDirErrorIsErrNoYAMLFilesFound pins that an empty +// directory's error wraps errNoYAMLFilesFound (via errors.Is), not just a +// matching message string -- runApply's --since tolerance for an empty +// directory (see TestApply_Since_AllFilesDeleted_DirectorySurvives in +// since_integration_test.go) depends on being able to detect this specific +// condition rather than any other discoverFiles failure. +func TestDiscoverFiles_EmptyDirErrorIsErrNoYAMLFilesFound(t *testing.T) { + dir := t.TempDir() + + _, err := discoverFiles(dir) + require.Error(t, err) + assert.ErrorIs(t, err, errNoYAMLFilesFound) +} + func TestDiscoverFiles_CaseInsensitiveExtensions(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "upper.YAML"), []byte("kind: Dashboard"), 0644)) diff --git a/internal/apply/since.go b/internal/apply/since.go index d11d0eed..4f250d92 100644 --- a/internal/apply/since.go +++ b/internal/apply/since.go @@ -50,24 +50,41 @@ func computeDeletionPlan(ctx context.Context, flags *applyFlags) (*deletionPlan, if err != nil { return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", flags.File, err) } + + // absFile may no longer exist on disk at all: every asset definition + // under -f's target may have been deleted, taking the directory itself + // with them. A --since run only needs *some* real, existing path inside + // the repository to locate its root -- not the target itself -- so walk + // up to the nearest existing ancestor instead of requiring absFile to + // exist, then reattach the missing suffix below so scope still reflects + // the target's (now-vanished) location. + existingAncestor, missingSuffix, err := nearestExistingAncestor(absFile) + if err != nil { + return nil, fmt.Errorf("failed to resolve %s: %w", flags.File, err) + } // Resolve symlinks so absFile is comparable with repo.Root()'s output: // `git rev-parse --show-toplevel` always prints the fully-resolved real // path, but filepath.Abs alone does not resolve symlinks in parent // directories (e.g. macOS's /var -> /private/var), which would otherwise // make every filepath.Rel(repoRoot, absFile) below compute a bogus // "outside the repository" path. - absFile, err = filepath.EvalSymlinks(absFile) + resolvedAncestor, err := filepath.EvalSymlinks(existingAncestor) if err != nil { return nil, fmt.Errorf("failed to resolve %s: %w", flags.File, err) } + absFile = filepath.Join(resolvedAncestor, missingSuffix) - info, err := os.Stat(absFile) - if err != nil { - return nil, fmt.Errorf("failed to stat %s: %w", flags.File, err) - } - repoDir := absFile - if !info.IsDir() { - repoDir = filepath.Dir(absFile) + repoDir := resolvedAncestor + if missingSuffix == "" { + // absFile exists: preserve the original file-vs-directory dance + // exactly as before. + info, err := os.Stat(absFile) + if err != nil { + return nil, fmt.Errorf("failed to stat %s: %w", flags.File, err) + } + if info.IsDir() { + repoDir = absFile + } } repo := gitutil.Repo{Dir: repoDir} @@ -138,6 +155,35 @@ func computeDeletionPlan(ctx context.Context, flags *applyFlags) (*deletionPlan, return &deletionPlan{plan: plan, warning: warning, names: names, scope: scope}, nil } +// nearestExistingAncestor walks up from path until it finds an entry that +// exists on disk, returning that ancestor plus the path components between +// it and path, joined back together with filepath.Join's separator so a +// caller can filepath.Join them straight onto the ancestor's symlink-resolved +// form. missingSuffix is "" when path itself already exists (the common +// case, unaffected by --since: the ancestor returned is then path itself). +// +// This lets computeDeletionPlan resolve a --since target that no longer +// exists on disk at all -- every asset definition under it may have been +// deleted, taking the directory with them -- without needing path itself to +// exist: locating the git repository only needs *some* real path inside it. +func nearestExistingAncestor(path string) (ancestor string, missingSuffix string, err error) { + current := path + var missing []string + for { + if _, statErr := os.Lstat(current); statErr == nil { + return current, filepath.Join(missing...), nil + } else if !os.IsNotExist(statErr) { + return "", "", statErr + } + parent := filepath.Dir(current) + if parent == current { + return "", "", fmt.Errorf("no existing ancestor directory found for %s", path) + } + missing = append([]string{filepath.Base(current)}, missing...) + current = parent + } +} + // resolveDeletionNames best-effort looks up each deletion's display name by // re-reading its content from git history at sha (the resolved --since // ref). Reads are cached per file so a multi-document file with several diff --git a/internal/apply/since_integration_test.go b/internal/apply/since_integration_test.go index ceb39261..6e0ca88d 100644 --- a/internal/apply/since_integration_test.go +++ b/internal/apply/since_integration_test.go @@ -80,6 +80,132 @@ spec: assert.Contains(t, output, "deleted") } +// TestApply_Since_AllFilesDeleted_DirectorySurvives is a regression test for +// a bug where --since found nothing to delete (in fact, failed the whole +// run outright) once every asset definition under -f's target had been +// removed and the (now-empty) directory itself survived: runApply's +// directory-discovery step (readDirectory) hard-failed with "no .yaml or +// .yml files found" before computeDeletionPlan ever got a chance to run, so +// --since's very purpose -- detecting an all-deletions run -- was +// unreachable for exactly the case it exists to handle. Unlike +// TestApply_Since_WholeFileDeletion, no "keep.yaml" survivor is written +// after the removal: the point of this test is that none is needed. +func TestApply_Since_AllFilesDeleted_DirectorySurvives(t *testing.T) { + testutil.SetupTestEnv(t) + + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "dashboard.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +`) + writeFileFixture(t, dir, "view.yaml", "apiVersion: dash0.com/v1alpha1\nkind: View\nmetadata:\n name: my-view\n labels:\n dash0.com/id: b2c3d4e5-6789-01bc-def0-234567890abc\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add dashboard and view") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(dir, "dashboard.yaml"))) + require.NoError(t, os.Remove(filepath.Join(dir, "view.yaml"))) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove both") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodDelete, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + server.OnPattern(http.MethodDelete, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", dir, "--since", before, "--force", "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.Contains(t, output, "a1b2c3d4-5678-90ab-cdef-1234567890ab") + assert.Contains(t, output, "b2c3d4e5-6789-01bc-def0-234567890abc") + assert.Contains(t, output, "deleted") +} + +// TestApply_Since_AllFilesDeleted_TargetDirectoryRemoved is the same +// all-deletions scenario as TestApply_Since_AllFilesDeleted_DirectorySurvives, +// except the -f target directory was removed entirely along with its files +// (rather than surviving empty) -- a plausible outcome of the same "delete +// everything" cleanup, and a second, independent failure mode of the +// original bug: os.Stat(flags.File) itself failed before runApply could even +// decide whether to treat the target as a directory. +func TestApply_Since_AllFilesDeleted_TargetDirectoryRemoved(t *testing.T) { + testutil.SetupTestEnv(t) + + repoRoot := t.TempDir() + runGitCmd(t, repoRoot, "init", "-q", "-b", "main") + runGitCmd(t, repoRoot, "config", "user.email", "test@example.com") + runGitCmd(t, repoRoot, "config", "user.name", "Test") + runGitCmd(t, repoRoot, "config", "commit.gpgsign", "false") + + writeFileFixture(t, repoRoot, "dashboards/dashboard.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +`) + runGitCmd(t, repoRoot, "add", "-A") + runGitCmd(t, repoRoot, "commit", "-q", "-m", "add dashboard") + before := strings.TrimSpace(runGitCmd(t, repoRoot, "rev-parse", "HEAD")) + + target := filepath.Join(repoRoot, "dashboards") + require.NoError(t, os.RemoveAll(target)) + runGitCmd(t, repoRoot, "add", "-A") + runGitCmd(t, repoRoot, "commit", "-q", "-m", "remove dashboards directory entirely") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodDelete, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", target, "--since", before, "--force", "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.Contains(t, output, "a1b2c3d4-5678-90ab-cdef-1234567890ab") + assert.Contains(t, output, "deleted") +} + // TestApply_Since_WholeFileDeletion_SubdirectoryScope is a regression test // for a bug where -f pointed at a subdirectory of the repo (rather than the // repo root) made --since silently report zero deletions: the git-side diff --git a/internal/apply/since_test.go b/internal/apply/since_test.go index b165cf92..6e5209b4 100644 --- a/internal/apply/since_test.go +++ b/internal/apply/since_test.go @@ -145,6 +145,198 @@ func TestComputeDeletionPlan_WholeFileDeletion(t *testing.T) { assert.Equal(t, "My Dashboard", dp.names[deletion.Path]) } +// testSinceRepoAllDeleted creates a temp git repo with two asset files at ref +// "before", then removes both of them (and nothing else) in a later commit, +// leaving the -f target directory itself still present on disk but with zero +// eligible YAML files -- the "all files deleted" scenario, as opposed to +// testSinceRepo's "one file survives" scenario. +func testSinceRepoAllDeleted(t *testing.T) (dir, beforeSHA string) { + t.Helper() + dir = t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "dashboard.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +`) + writeFileFixture(t, dir, "view.yaml", "apiVersion: dash0.com/v1alpha1\nkind: View\nmetadata:\n name: my-view\n labels:\n dash0.com/id: b2c3d4e5-6789-01bc-def0-234567890abc\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add dashboard and view") + beforeSHA = strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(dir, "dashboard.yaml"))) + require.NoError(t, os.Remove(filepath.Join(dir, "view.yaml"))) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove both") + + return dir, beforeSHA +} + +// TestComputeDeletionPlan_AllFilesDeleted is a regression test for a bug +// where --since silently found nothing to delete once every asset +// definition under -f's target was removed and the (now-empty) directory +// itself survived: computeDeletionPlan itself has always tolerated an empty +// disk-side scope fine (BuildSnapshotFromDisk over an empty directory finds +// nothing to ingest, which is not an error) -- the actual bug lived one layer +// up, in runApply's directory-discovery step (readDirectory), which hard- +// failed with "no .yaml or .yml files found" before computeDeletionPlan ever +// ran. This test pins computeDeletionPlan's own (already-correct) contract; +// TestApply_Since_AllFilesDeleted_DirectorySurvives in +// since_integration_test.go covers the full runApply path that used to fail. +func TestComputeDeletionPlan_AllFilesDeleted(t *testing.T) { + dir, before := testSinceRepoAllDeleted(t) + + flags := &applyFlags{File: dir, Since: before} + dp, err := computeDeletionPlan(context.Background(), flags) + require.NoError(t, err) + require.Len(t, dp.plan.ByIdentifier, 2) + + identifiers := []string{dp.plan.ByIdentifier[0].Identifier, dp.plan.ByIdentifier[1].Identifier} + assert.ElementsMatch(t, []string{"a1b2c3d4-5678-90ab-cdef-1234567890ab", "b2c3d4e5-6789-01bc-def0-234567890abc"}, identifiers) + assert.Empty(t, dp.warning) +} + +// TestComputeDeletionPlan_TargetDirectoryRemoved is a regression test for a +// bug where computeDeletionPlan itself couldn't run at all once the -f +// target directory was removed entirely (not just emptied): filepath. +// EvalSymlinks and os.Stat both require the path to exist, and both were +// called directly on the target before this fix. This exercises the case +// where the target is a subdirectory of the repo (not the repo root itself, +// which can never be "removed" while still being a git worktree) that no +// longer exists on disk at all. +func TestComputeDeletionPlan_TargetDirectoryRemoved(t *testing.T) { + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "dashboards/dashboard.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add dashboard") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + target := filepath.Join(dir, "dashboards") + require.NoError(t, os.RemoveAll(target)) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove dashboards directory entirely") + + flags := &applyFlags{File: target, Since: before} + dp, err := computeDeletionPlan(context.Background(), flags) + require.NoError(t, err) + require.Len(t, dp.plan.ByIdentifier, 1) + assert.Equal(t, "a1b2c3d4-5678-90ab-cdef-1234567890ab", dp.plan.ByIdentifier[0].Identifier) + assert.Empty(t, dp.warning) +} + +// TestComputeDeletionPlan_SubdirectoryRenamedWithinScope pins the documented +// contract that deletion detection is by identifier, never by file path +// (see "Deletion detection is by identifier" in docs/commands.md's --since +// section): renaming a subdirectory *within* -f's scanned scope must not be +// reported as a deletion, since the asset's identifier survives under the +// new path. TestDiff_NoChangeWhenIdentifierSurvives in internal/git/diff_test.go +// already pins this at the pure Snapshot/Diff level with hand-built +// Snapshots; this test exercises the same contract through computeDeletionPlan +// end to end, against a real git repo with an actual `git mv` of a directory +// (not just a single file), which is what a user restructuring a dashboards/ +// tree by team or environment would actually do. +func TestComputeDeletionPlan_SubdirectoryRenamedWithinScope(t *testing.T) { + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "dashboards/team-a/dashboard.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add dashboard under team-a") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + // -f itself (the "dashboards" directory below) is unaffected by this + // rename -- only the subdirectory nested inside it moves. + runGitCmd(t, dir, "mv", "dashboards/team-a", "dashboards/team-b") + runGitCmd(t, dir, "commit", "-q", "-m", "rename team-a to team-b") + + flags := &applyFlags{File: filepath.Join(dir, "dashboards"), Since: before} + dp, err := computeDeletionPlan(context.Background(), flags) + require.NoError(t, err) + assert.True(t, dp.plan.IsEmpty(), "renaming a subdirectory within the scanned scope must not be reported as a deletion") + assert.Empty(t, dp.warning) +} + +// TestComputeDeletionPlan_TargetItselfRenamedIsReportedAsDeletion documents +// the necessary counterpart to +// TestComputeDeletionPlan_SubdirectoryRenamedWithinScope: identifier-based +// matching only reaches as far as -f's own scope. If -f's *own* target +// directory is what gets renamed (as opposed to something nested inside it) +// and the caller keeps pointing -f at the old, now-gone path, every asset +// that used to live there is correctly reported as deleted -- from that +// fixed scope's perspective, it genuinely no longer has anything, the exact +// case TestComputeDeletionPlan_TargetDirectoryRemoved and +// TestComputeDeletionPlan_AllFilesDeleted exist to detect. Re-pointing -f at +// the new path instead (not exercised here) reports zero deletions, since +// the "before" snapshot for that new scope has nothing to diff against -- +// the identifier simply becomes a new create/update, business as usual. +func TestComputeDeletionPlan_TargetItselfRenamedIsReportedAsDeletion(t *testing.T) { + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "dashboards/dashboard.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add dashboard") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + runGitCmd(t, dir, "mv", "dashboards", "dashboards-v2") + runGitCmd(t, dir, "commit", "-q", "-m", "rename dashboards to dashboards-v2") + + // -f still names the old path, which the rename left nonexistent. + flags := &applyFlags{File: filepath.Join(dir, "dashboards"), Since: before} + dp, err := computeDeletionPlan(context.Background(), flags) + require.NoError(t, err) + require.Len(t, dp.plan.ByIdentifier, 1) + assert.Equal(t, "a1b2c3d4-5678-90ab-cdef-1234567890ab", dp.plan.ByIdentifier[0].Identifier) + assert.Empty(t, dp.warning) +} + // TestSplitMultiDocPath is a table test for the "#" suffix // internal/git/snapshot.go appends to the second and later documents' paths // in a multi-document file. diff --git a/internal/git/snapshot.go b/internal/git/snapshot.go index fac5b8c8..d3705b1b 100644 --- a/internal/git/snapshot.go +++ b/internal/git/snapshot.go @@ -132,9 +132,20 @@ func BuildSnapshotFromRef(ctx context.Context, repo Repo, ref, scope string) (Sn // but taking one keeps the signature consistent with the rest of this // package's public API and forward-compatible with future callers that need // to bound how long a large directory scan can run. +// +// scope not existing on disk at all is not an error: every asset definition +// under it may have been deleted, taking the directory itself with them (or, +// for a single-file scope, the one file it named). That carries the same +// meaning as an existing-but-empty directory -- the "after" state has +// nothing -- so every identifier the "before" snapshot (BuildSnapshotFromRef) +// found becomes a deletion candidate, the same as it would for a survived, +// merely-emptied directory. func BuildSnapshotFromDisk(ctx context.Context, scope, repoRoot string) (Snapshot, error) { info, err := os.Stat(scope) if err != nil { + if os.IsNotExist(err) { + return newSnapshot(), nil + } return Snapshot{}, fmt.Errorf("failed to stat %s: %w", scope, err) } diff --git a/internal/git/snapshot_test.go b/internal/git/snapshot_test.go index 236a5130..5401f8f3 100644 --- a/internal/git/snapshot_test.go +++ b/internal/git/snapshot_test.go @@ -178,6 +178,22 @@ func TestBuildSnapshotFromDisk_SingleFileScopeIgnoresExtension(t *testing.T) { assert.Contains(t, snap.Identifiers, IdentifierKey{Kind: "dashboard", Identifier: "a1b2c3d4-5678-90ab-cdef-1234567890ab"}, "a single-file scope must be scanned regardless of its extension") } +// TestBuildSnapshotFromDisk_ScopeDoesNotExist is a regression test for a bug +// where a --since target that no longer exists on disk at all -- every asset +// definition under it was deleted, taking the directory (or, for a +// single-file scope, the one file it named) with them -- hard-failed the +// "after" snapshot with "failed to stat", instead of being treated the same +// as an existing-but-empty directory: nothing currently there, so everything +// found in the "before" (git-ref) snapshot is a deletion candidate. +func TestBuildSnapshotFromDisk_ScopeDoesNotExist(t *testing.T) { + repo := testRepo(t) + + snap, err := BuildSnapshotFromDisk(context.Background(), repo.Dir+"/never-existed", repo.Dir) + require.NoError(t, err) + assert.Empty(t, snap.Identifiers) + assert.Empty(t, snap.Paths) +} + func TestBuildSnapshotFromDisk_PathsAlignWithRepoRootWhenScopeIsSubdirectory(t *testing.T) { repo := testRepo(t) writeFile(t, repo.Dir, "sub/dashboard.yaml", dashboardYAML) diff --git a/internal/skill/content/references/apply.md b/internal/skill/content/references/apply.md index 7058c400..99ac21e1 100644 --- a/internal/skill/content/references/apply.md +++ b/internal/skill/content/references/apply.md @@ -170,6 +170,21 @@ alerts.yaml: PrometheusRule "service-alerts" (c3d4e5f6-...) created Check rule "service-alerts - HighLatency" deleted ``` +When every asset definition under `-f`'s target has been deleted, `--since` still detects and reports every one of them, rather than failing outright. +This holds whether the target directory survives (now empty of `.yaml`/`.yml` files) or was removed entirely along with its files (e.g. `rm -rf dashboards/`) — both count as "nothing currently there," so every asset found at `` becomes a deletion candidate: + +```bash +$ dash0 --experimental apply -f dashboards/ --since HEAD~1 --dry-run +Dry run: 0 documents from 0 files validated; 2 deletions pending due to --since 'HEAD~1' + dashboard-a.yaml + * Delete Dashboard "Dashboard A" (dash-a) + view-b.yaml + * Delete View "View B" (view-b) +``` + +Without `--since`, an empty or missing `-f` target is a plain usage error instead — `apply` on its own has nothing to fall back to when there is nothing to apply, so it fails fast rather than silently doing nothing. +`--since` changes this because "everything under this target was deleted" is itself the meaningful, actionable outcome it exists to detect. + `--since` needs a real `git` binary on `PATH`. It is unavailable from the `ghcr.io/dash0hq/cli` Docker image, which is built `FROM scratch` and has no shell or other tools installed. diff --git a/internal/testutil/fixtures/git-scenarios/directory-rename.yml b/internal/testutil/fixtures/git-scenarios/directory-rename.yml new file mode 100644 index 00000000..21826b31 --- /dev/null +++ b/internal/testutil/fixtures/git-scenarios/directory-rename.yml @@ -0,0 +1,45 @@ +kind: GitRepoFixture +spec: + # A dashboard file is moved from one subdirectory to another between the + # ref and HEAD (its containing directory renamed) -- deletion detection is + # by identifier, never by file path, so this must not be treated as a + # deletion. Represented as a delete-and-add pair with identical content + # rather than a literal `git mv`: BuildGitScenario applies changes as plain + # file writes plus `git add -A` (see internal/testutil/gitscenario.go), and + # --since's own snapshot comparison (internal/git/snapshot.go) scans each + # side's full tree rather than diffing successive commits, so the two are + # indistinguishable to the code under test. + sinceRef: before + repo: + commits: + - label: before + message: "add dashboard-a under team-a" + changes: + - op: add + name: team-a/dashboard-a.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: Dashboard + metadata: + name: Dashboard A + dash0Extensions: + id: dash-a + spec: + display: + name: Dashboard A + - message: "rename team-a to team-b" + changes: + - op: delete + name: team-a/dashboard-a.yaml + - op: add + name: team-b/dashboard-a.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: Dashboard + metadata: + name: Dashboard A + dash0Extensions: + id: dash-a + spec: + display: + name: Dashboard A diff --git a/internal/testutil/fixtures/git-scenarios/whole-directory-deletion.yml b/internal/testutil/fixtures/git-scenarios/whole-directory-deletion.yml new file mode 100644 index 00000000..7c434b78 --- /dev/null +++ b/internal/testutil/fixtures/git-scenarios/whole-directory-deletion.yml @@ -0,0 +1,40 @@ +kind: GitRepoFixture +spec: + # A commit with two asset files, followed by a commit removing both -- + # the -f target directory survives (it's the repo root) but ends up with + # zero eligible YAML files, unlike whole-file-deletion where one survives. + sinceRef: before + repo: + commits: + - label: before + message: "add dashboard-a and view-b" + changes: + - op: add + name: dashboard-a.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: Dashboard + metadata: + name: Dashboard A + dash0Extensions: + id: dash-a + spec: + display: + name: Dashboard A + - op: add + name: view-b.yaml + content: | + apiVersion: dash0.com/v1alpha1 + kind: View + metadata: + name: View B + labels: + dash0.com/id: view-b + spec: + query: "true" + - message: "remove dashboard-a and view-b" + changes: + - op: delete + name: dashboard-a.yaml + - op: delete + name: view-b.yaml diff --git a/internal/testutil/gitscenario_test.go b/internal/testutil/gitscenario_test.go index e717dff4..9fc210d9 100644 --- a/internal/testutil/gitscenario_test.go +++ b/internal/testutil/gitscenario_test.go @@ -88,6 +88,38 @@ func TestBuildGitScenario_WholeFileDeletion(t *testing.T) { assert.Contains(t, atRef, "dash-a") } +func TestBuildGitScenario_DirectoryRename(t *testing.T) { + repoDir, ref := BuildGitScenario(t, "directory-rename") + + assert.Len(t, ref, 40, "ref should be a full commit SHA") + assert.NoFileExists(t, filepath.Join(repoDir, "team-a", "dashboard-a.yaml"), "the old path must not exist at HEAD") + assert.FileExists(t, filepath.Join(repoDir, "team-b", "dashboard-a.yaml")) + + atRef := runGitScenario(t, repoDir, "cat-file", "-p", ref+":team-a/dashboard-a.yaml") + assert.Contains(t, atRef, "dash-a") +} + +func TestBuildGitScenario_WholeDirectoryDeletion(t *testing.T) { + repoDir, ref := BuildGitScenario(t, "whole-directory-deletion") + + assert.Len(t, ref, 40, "ref should be a full commit SHA") + assert.NoFileExists(t, filepath.Join(repoDir, "dashboard-a.yaml"), "the deleted file must not exist at HEAD") + assert.NoFileExists(t, filepath.Join(repoDir, "view-b.yaml"), "the deleted file must not exist at HEAD") + + entries, err := os.ReadDir(repoDir) + require.NoError(t, err) + var nonGitEntries []string + for _, e := range entries { + if e.Name() != ".git" { + nonGitEntries = append(nonGitEntries, e.Name()) + } + } + assert.Empty(t, nonGitEntries, "the repo's working tree should have nothing left besides .git") + + atRef := runGitScenario(t, repoDir, "cat-file", "-p", ref+":dashboard-a.yaml") + assert.Contains(t, atRef, "dash-a") +} + func TestBuildGitScenario_MultiDocumentPartialDeletion(t *testing.T) { repoDir, ref := BuildGitScenario(t, "multi-document-partial-deletion") diff --git a/test/e2e/since_e2e_test.go b/test/e2e/since_e2e_test.go index 08c0e224..fa0edb61 100644 --- a/test/e2e/since_e2e_test.go +++ b/test/e2e/since_e2e_test.go @@ -114,6 +114,69 @@ func TestE2E_ApplySince_WholeFileDeletion(t *testing.T) { } } +// TestE2E_ApplySince_WholeDirectoryDeletion covers the same all-deletions +// scenario as TestE2E_ApplySince_WholeFileDeletion but with every file under +// -f's target removed (not just one of several) -- the case that used to +// hard-fail with "no .yaml or .yml files found" before computeDeletionPlan +// ever ran, since -f here points at the repo root, which git always leaves +// on disk even once every tracked file under it is gone. +func TestE2E_ApplySince_WholeDirectoryDeletion(t *testing.T) { + ctx := context.Background() + repoDir, ref := testutil.BuildGitScenario(t, "whole-directory-deletion") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodDelete, dashboardIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, Body: map[string]any{}}) + server.OnPattern(http.MethodDelete, viewIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, Body: map[string]any{}}) + + container := startContainer(ctx, t, mockServerPort(t, server)) + copyScenarioIntoContainer(ctx, t, container, repoDir) + + exitCode, output := execDash0(ctx, t, container, mockServerPort(t, server), + "--experimental", "apply", "-f", "/work/repo", "--since", ref, "--force") + + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d. Output:\n%s", exitCode, output) + } + if !strings.Contains(output, "deleted") { + t.Errorf("expected output to mention a deletion, got:\n%s", output) + } +} + +// TestE2E_ApplySince_DirectoryRenameIsNotADeletion pins the documented +// contract that deletion detection is by identifier, never by file path +// (see "Deletion detection is by identifier" in docs/commands.md's --since +// section): a dashboard moved into a differently-named subdirectory between +// the ref and HEAD must be treated as a plain update at its new path, not a +// deletion. No DELETE route is registered on the mock server -- if the fix +// regressed and the rename were (mis)treated as a deletion, the delete +// call would hit the mock server's default handler and the exit-code/output +// checks below would surface it, the same technique +// TestE2E_ApplySince_PrometheusRecordingPartialRemovalIsNotADeletion uses. +func TestE2E_ApplySince_DirectoryRenameIsNotADeletion(t *testing.T) { + ctx := context.Background() + repoDir, ref := testutil.BuildGitScenario(t, "directory-rename") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, dashboardIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, BodyFile: testutil.FixtureDashboardsGetSuccess}) + server.OnPattern(http.MethodPut, dashboardIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, BodyFile: testutil.FixtureDashboardsImportSuccess}) + + container := startContainer(ctx, t, mockServerPort(t, server)) + copyScenarioIntoContainer(ctx, t, container, repoDir) + + exitCode, output := execDash0(ctx, t, container, mockServerPort(t, server), + "--experimental", "apply", "-f", "/work/repo", "--since", ref, "--force") + + if exitCode != 0 { + t.Fatalf("expected exit 0, got %d. Output:\n%s", exitCode, output) + } + if strings.Contains(output, "deleted") { + t.Errorf("renaming a subdirectory within the scanned scope must not be treated as a deletion, got:\n%s", output) + } + if !strings.Contains(output, "Dashboard") { + t.Errorf("expected output to mention the dashboard being applied at its new path, got:\n%s", output) + } +} + func TestE2E_ApplySince_MultiDocumentPartialDeletion(t *testing.T) { ctx := context.Background() repoDir, ref := testutil.BuildGitScenario(t, "multi-document-partial-deletion") From 824801014d419dd5c6043aeb4d8c9ca6e3f9195c Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 24 Aug 2026 15:29:47 +0200 Subject: [PATCH 14/42] fix(apply): always attempt both endpoints when deleting a PrometheusRule CRD --since decided which of the check-rule/recording-rule endpoints to call by trusting the CRD's content at --since's own ref -- a single point in time, not a history of everything the identifier has ever used. A CRD that had its record entry dropped (keeping the alert) in an earlier commit, then had the whole file deleted later, showed --since a ref where the file only ever had an alert: the recording rule created back when the file was still mixed was silently orphaned and permanently unrecoverable, since no later git state can prove it ever existed once the file is gone. Both endpoints are now always attempted, tolerating a 404 from whichever the CRD never used, the same idempotent-delete pattern every other kind already relies on. This reopens a narrow, previously- guarded-against edge case -- an unrelated check rule and recording rule that happen to share the same identifier by coincidence would both be deleted together -- accepted as a tradeoff against silently leaving real orphaned assets behind. Removes the now-unused endpoint-tracking machinery this replaces: internal/git's PrometheusRuleEndpoints type and PrometheusRuleEndpointsByIdentifier snapshot field, and internal/asset.PrometheusRuleEndpoints. --- internal/apply/since.go | 58 ++++------ internal/apply/since_integration_test.go | 140 +++++++++++++++++++---- internal/asset/prometheusrule.go | 27 ----- internal/asset/prometheusrule_test.go | 68 ----------- internal/git/diff.go | 17 +-- internal/git/snapshot.go | 33 +----- 6 files changed, 151 insertions(+), 192 deletions(-) diff --git a/internal/apply/since.go b/internal/apply/since.go index 4f250d92..0d37bc50 100644 --- a/internal/apply/since.go +++ b/internal/apply/since.go @@ -304,7 +304,7 @@ func applyDeletions(ctx context.Context, apiClient dash0api.Client, dataset *str func deleteAssetByKindAndIdentifier(ctx context.Context, apiClient dash0api.Client, dataset *string, d gitutil.Deletion, force bool) error { kind, identifier := d.Kind, d.Identifier if kind == "prometheusrule" { - return deletePrometheusRuleCRD(ctx, apiClient, dataset, identifier, d.PrometheusRuleEndpoints, force) + return deletePrometheusRuleCRD(ctx, apiClient, dataset, identifier, force) } var err error @@ -343,28 +343,24 @@ func deleteAssetByKindAndIdentifier(ctx context.Context, apiClient dash0api.Clie // create/update dispatch (applyPrometheusRule) sends a mixed CRD to both // endpoints, so a mixed CRD's deletion attempts both too. // -// endpoints (extracted from the CRD's content at --since's ref, before it -// was deleted) says which endpoint(s) the CRD actually used. Only those are -// called: unconditionally attempting both and tolerating a 404 from -// whichever wasn't used would silently delete an unrelated asset that -// happens to carry the same identifier on the endpoint this CRD never used. -// If endpoints reports neither (only possible for a Snapshot built before -// this field existed, or corrupted git history), both are attempted and a -// 404 from either is tolerated, matching the old best-effort behavior. -func deletePrometheusRuleCRD(ctx context.Context, apiClient dash0api.Client, dataset *string, identifier string, endpoints gitutil.PrometheusRuleEndpoints, force bool) error { - tryCheckRule := endpoints.HasAlerts - tryRecordingRule := endpoints.HasRecords - if !tryCheckRule && !tryRecordingRule { - tryCheckRule, tryRecordingRule = true, true - } - - var checkRuleErr, recordingRuleErr error - if tryCheckRule { - checkRuleErr = apiClient.DeleteCheckRule(ctx, identifier, dataset) - } - if tryRecordingRule { - recordingRuleErr = apiClient.DeleteRecordingRule(ctx, identifier, dataset) - } +// Both endpoints are always attempted, tolerating a 404 from either: this +// used to be gated on which endpoint(s) the CRD's content at --since's ref +// showed it using, but that signal is a single point in time, not a history +// of everything the identifier has ever used. A CRD that had a recording +// rule stripped from it in an earlier commit (leaving only its alerting +// rules), followed by the whole file being deleted, showed --since a ref +// where the file only ever had alerts — silently orphaning the recording +// rule created earlier, with no later git state able to recover that fact +// once the file is gone. Deleting is naturally idempotent (a 404 just means +// this endpoint never had anything for this identifier), so attempting both +// unconditionally is safe by the same logic every other kind already +// relies on. The one tradeoff: a check rule and a recording rule that +// happen to share the same identifier by coincidence (not because they came +// from the same CRD) would both be deleted together — accepted as an edge +// case narrow enough not to justify leaving real orphaned assets behind. +func deletePrometheusRuleCRD(ctx context.Context, apiClient dash0api.Client, dataset *string, identifier string, force bool) error { + checkRuleErr := apiClient.DeleteCheckRule(ctx, identifier, dataset) + recordingRuleErr := apiClient.DeleteRecordingRule(ctx, identifier, dataset) checkRuleNotFound := checkRuleErr != nil && dash0api.IsNotFound(checkRuleErr) recordingRuleNotFound := recordingRuleErr != nil && dash0api.IsNotFound(recordingRuleErr) @@ -384,20 +380,14 @@ func deletePrometheusRuleCRD(ctx context.Context, apiClient dash0api.Client, dat return client.HandleAPIError(recordingRuleErr, ectx) } - // "Genuinely gone" means 404 on every endpoint that was actually tried — - // an endpoint that was never tried (because the CRD didn't use it) - // contributes no signal either way. - genuinelyGone := (!tryCheckRule || checkRuleNotFound) && (!tryRecordingRule || recordingRuleNotFound) - if genuinelyGone { + // "Genuinely gone" means 404 on both endpoints -- neither had anything + // for this identifier. + if checkRuleNotFound && recordingRuleNotFound { ectx := client.ErrorContext{AssetType: "PrometheusRule", AssetID: identifier} - firstErr := checkRuleErr - if firstErr == nil { - firstErr = recordingRuleErr - } - if client.IsAlreadyDeleted(firstErr, force, ectx) { + if client.IsAlreadyDeleted(checkRuleErr, force, ectx) { return nil } - return client.HandleAPIError(firstErr, ectx) + return client.HandleAPIError(checkRuleErr, ectx) } return nil } diff --git a/internal/apply/since_integration_test.go b/internal/apply/since_integration_test.go index 6e0ca88d..51a37e67 100644 --- a/internal/apply/since_integration_test.go +++ b/internal/apply/since_integration_test.go @@ -438,22 +438,24 @@ spec: assert.NotContains(t, stderr, "persesDashboard", "the kind name must never be turned into an invented hybrid casing either") } -// TestApply_Since_PrometheusRuleWholeCRDDeletion_AlertingOnlyDoesNotTouchRecordingRules +// TestApply_Since_PrometheusRuleWholeCRDDeletion_RecordDroppedBeforeDeletion_StillDeletesRecordingRule // is a regression test for a bug where deleting a whole PrometheusRule CRD -// unconditionally attempted DELETE on both the check-rules and -// recording-rules endpoints, tolerating a 404 from whichever the CRD didn't -// use. If an unrelated, still-live recording rule happened to share the same -// identifier (a coincidental id collision — the two asset types have -// entirely separate id spaces on the server, so this is possible), the old -// code would silently delete it too, since a successful DELETE there looks -// identical to "the CRD used this endpoint." The fix carries forward which -// endpoint(s) the CRD's content actually used (from the git ref before it -// was deleted), so an alerting-only CRD's deletion is dispatched to -// check-rules only. -func TestApply_Since_PrometheusRuleWholeCRDDeletion_AlertingOnlyDoesNotTouchRecordingRules(t *testing.T) { +// undercounted which endpoints to clean up, based solely on the CRD's +// content at --since's own ref -- a single point in time, not a history of +// everything the identifier has ever used. A CRD that starts out mixed +// (alert + record), then has its record entry dropped in an earlier commit +// while keeping the alert, then is deleted entirely, shows --since a ref +// where the file only ever had an alert: the old code carried that stale +// "alerting-only" signal straight into the delete dispatch and never called +// DELETE on the recording-rules endpoint at all, permanently orphaning the +// recording rule created back when the file was still mixed -- nothing +// after the file is gone can ever recover that fact from git. The fix +// always attempts both endpoints (tolerating a 404 from whichever wasn't +// actually used), so the orphaned recording rule is cleaned up too. +func TestApply_Since_PrometheusRuleWholeCRDDeletion_RecordDroppedBeforeDeletion_StillDeletesRecordingRule(t *testing.T) { testutil.SetupTestEnv(t) - const sharedID = "shared-id-collision" + const id = "app-rules-id" dir := t.TempDir() runGitCmd(t, dir, "init", "-q", "-b", "main") @@ -464,24 +466,42 @@ func TestApply_Since_PrometheusRuleWholeCRDDeletion_AlertingOnlyDoesNotTouchReco writeFileFixture(t, dir, "rules.yaml", `apiVersion: monitoring.coreos.com/v1 kind: PrometheusRule metadata: - name: alerting-only-rules + name: app-rules labels: - dash0.com/id: `+sharedID+` + dash0.com/id: `+id+` spec: groups: - name: test-group rules: - alert: HighErrorRate expr: sum(rate(errors[5m])) > 0.1 + - record: my_record + expr: rate(x[5m]) `) runGitCmd(t, dir, "add", "-A") - runGitCmd(t, dir, "commit", "-q", "-m", "add alerting-only rules") + runGitCmd(t, dir, "commit", "-q", "-m", "add mixed rules") + + writeFileFixture(t, dir, "rules.yaml", `apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: app-rules + labels: + dash0.com/id: `+id+` +spec: + groups: + - name: test-group + rules: + - alert: HighErrorRate + expr: sum(rate(errors[5m])) > 0.1 +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "drop the record, keep the alert") before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) require.NoError(t, os.Remove(filepath.Join(dir, "rules.yaml"))) writeFileFixture(t, dir, "keep.yaml", "apiVersion: dash0.com/v1alpha1\nkind: View\nmetadata:\n name: keep\n labels:\n dash0.com/id: keep-id\nspec:\n query: \"true\"\n") runGitCmd(t, dir, "add", "-A") - runGitCmd(t, dir, "commit", "-q", "-m", "remove alerting-only rules") + runGitCmd(t, dir, "commit", "-q", "-m", "remove rules.yaml entirely") server := testutil.NewMockServer(t, testutil.FixturesDir()) server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{ @@ -494,9 +514,9 @@ spec: Body: map[string]any{}, Validator: testutil.RequireHeaders, }) - // An unrelated, still-live recording rule that happens to share the same - // identifier: registered to SUCCEED, so this test fails loudly if the - // buggy code path calls it at all. + // The recording rule created back when rules.yaml was still mixed -- + // still live server-side, even though the ref --since compares against + // only ever shows the file as alerting-only. server.OnPattern(http.MethodDelete, recordingRuleIDPattern, testutil.MockResponse{ StatusCode: http.StatusOK, Body: map[string]any{}, @@ -516,11 +536,85 @@ spec: require.NoError(t, cmdErr) assert.Contains(t, output, "PrometheusRule") - assert.Contains(t, output, sharedID) + assert.Contains(t, output, id) + assert.Contains(t, output, "deleted") + + require.NotNil(t, findRequest(server.Requests(), http.MethodDelete, "/api/alerting/check-rules/"+id), "expected the CRD's check rule to be deleted") + require.NotNil(t, findRequest(server.Requests(), http.MethodDelete, "/api/recording-rules/"+id), "expected the recording rule orphaned by dropping the record entry to be deleted too, even though --since's ref only ever showed the file as alerting-only") +} + +// TestApply_Since_PrometheusRuleWholeCRDDeletion_ToleratesRecordingRule404 +// pins the safety side of the fix above: an alerting-only CRD that never had +// a recording rule still has DELETE attempted against the recording-rules +// endpoint (since the code no longer knows, or needs to know, whether it +// ever used it) -- that attempt must 404 harmlessly and not fail the run. +func TestApply_Since_PrometheusRuleWholeCRDDeletion_ToleratesRecordingRule404(t *testing.T) { + testutil.SetupTestEnv(t) + + const id = "alerting-only-id" + + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "rules.yaml", `apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: alerting-only-rules + labels: + dash0.com/id: `+id+` +spec: + groups: + - name: test-group + rules: + - alert: HighErrorRate + expr: sum(rate(errors[5m])) > 0.1 +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add alerting-only rules") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(dir, "rules.yaml"))) + writeFileFixture(t, dir, "keep.yaml", "apiVersion: dash0.com/v1alpha1\nkind: View\nmetadata:\n name: keep\n labels:\n dash0.com/id: keep-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove alerting-only rules") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureViewsNotFound, + }) + server.WithViewsUpdate(testutil.FixtureViewsImportSuccess) + server.OnPattern(http.MethodDelete, checkRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + server.OnPattern(http.MethodDelete, recordingRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureRecordingRulesNotFound, + }) + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", dir, "--since", before, "--force", "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.Contains(t, output, "PrometheusRule") + assert.Contains(t, output, id) assert.Contains(t, output, "deleted") - require.NotNil(t, findRequest(server.Requests(), http.MethodDelete, "/api/alerting/check-rules/"+sharedID), "expected the alerting-only CRD's check rule to be deleted") - assert.Nil(t, findRequest(server.Requests(), http.MethodDelete, "/api/recording-rules/"+sharedID), "an alerting-only CRD must never call DELETE on the recording-rules endpoint, even if something there happens to share its identifier") + require.NotNil(t, findRequest(server.Requests(), http.MethodDelete, "/api/alerting/check-rules/"+id)) + require.NotNil(t, findRequest(server.Requests(), http.MethodDelete, "/api/recording-rules/"+id), "the recording-rules endpoint must still be attempted even for a CRD that never had a recording rule") } func TestApply_Since_MultiDocumentPartialDeletion(t *testing.T) { diff --git a/internal/asset/prometheusrule.go b/internal/asset/prometheusrule.go index a5145d9e..9cacf046 100644 --- a/internal/asset/prometheusrule.go +++ b/internal/asset/prometheusrule.go @@ -7,7 +7,6 @@ import ( dash0api "github.com/dash0hq/dash0-api-client-go" dash0yaml "github.com/dash0hq/dash0-api-client-go/yaml" "gopkg.in/yaml.v3" - sigsyaml "sigs.k8s.io/yaml" ) // ParseCheckRules parses a CheckRule or PrometheusRule CRD document into one or @@ -31,32 +30,6 @@ func ParseCheckRules(data []byte) ([]*dash0api.PrometheusAlertRule, error) { return rules, nil } -// PrometheusRuleEndpoints reports which of the two Dash0 endpoints (check -// rules for alerting rules, recording rules for recording rules) a -// PrometheusRule CRD document actually uses. Returns false, false for a -// document that isn't a PrometheusRule CRD at all. -// -// --since uses this to delete a removed CRD only from the endpoint(s) it -// actually used, instead of unconditionally attempting both and tolerating a -// 404 from whichever wasn't used — an id happening to also exist, -// coincidentally, on the endpoint the CRD never used would otherwise be -// silently deleted too. -func PrometheusRuleEndpoints(data []byte) (hasAlerts, hasRecords bool, err error) { - kind, err := dash0yaml.DetectKind(data) - if err != nil { - return false, false, err - } - if !strings.EqualFold(kind, "PrometheusRule") { - return false, false, nil - } - - var crd dash0api.RecordingRule - if err := sigsyaml.Unmarshal(data, &crd); err != nil { - return false, false, fmt.Errorf("failed to parse PrometheusRule: %w", err) - } - return PrometheusRuleHasAlerts(&crd), RecordingOnlyPrometheusRule(&crd) != nil, nil -} - // composePrometheusRuleNames rewrites the name of each check rule produced from // a PrometheusRule CRD to " - ". It is a no-op for // plain CheckRule documents. diff --git a/internal/asset/prometheusrule_test.go b/internal/asset/prometheusrule_test.go index e3725f94..f40c9c97 100644 --- a/internal/asset/prometheusrule_test.go +++ b/internal/asset/prometheusrule_test.go @@ -109,74 +109,6 @@ spec: assert.Equal(t, "g - N", names[1].CheckRuleName()) } -func TestPrometheusRuleEndpoints_AlertingOnly(t *testing.T) { - crd := []byte(`apiVersion: monitoring.coreos.com/v1 -kind: PrometheusRule -metadata: - name: alerting-only -spec: - groups: - - name: group-a - rules: - - alert: HighErrorRate - expr: errors > 0 -`) - hasAlerts, hasRecords, err := PrometheusRuleEndpoints(crd) - require.NoError(t, err) - assert.True(t, hasAlerts) - assert.False(t, hasRecords) -} - -func TestPrometheusRuleEndpoints_RecordingOnly(t *testing.T) { - crd := []byte(`apiVersion: monitoring.coreos.com/v1 -kind: PrometheusRule -metadata: - name: recording-only -spec: - groups: - - name: group-a - rules: - - record: instance:cpu:avg - expr: avg(cpu) -`) - hasAlerts, hasRecords, err := PrometheusRuleEndpoints(crd) - require.NoError(t, err) - assert.False(t, hasAlerts) - assert.True(t, hasRecords) -} - -func TestPrometheusRuleEndpoints_Mixed(t *testing.T) { - crd := []byte(`apiVersion: monitoring.coreos.com/v1 -kind: PrometheusRule -metadata: - name: mixed -spec: - groups: - - name: group-a - rules: - - alert: HighErrorRate - expr: errors > 0 - - record: instance:cpu:avg - expr: avg(cpu) -`) - hasAlerts, hasRecords, err := PrometheusRuleEndpoints(crd) - require.NoError(t, err) - assert.True(t, hasAlerts) - assert.True(t, hasRecords) -} - -func TestPrometheusRuleEndpoints_NonPrometheusRuleKind(t *testing.T) { - doc := []byte(`kind: CheckRule -id: some-id -name: High Error Rate -expression: up == 0 -`) - hasAlerts, hasRecords, err := PrometheusRuleEndpoints(doc) - require.NoError(t, err) - assert.False(t, hasAlerts) - assert.False(t, hasRecords) -} - func TestParseCheckRules_PlainCheckRuleKeepsName(t *testing.T) { doc := []byte(`kind: CheckRule id: b2c3d4e5-6789-01bc-def0-234567890abc diff --git a/internal/git/diff.go b/internal/git/diff.go index ccfbe78d..1048f6f8 100644 --- a/internal/git/diff.go +++ b/internal/git/diff.go @@ -16,14 +16,6 @@ type Deletion struct { // kept for diagnostic/logging purposes only — deletion is dispatched by // (Kind, Identifier), never by path. Path string - // PrometheusRuleEndpoints records which Dash0 endpoint(s) the deleted - // CRD actually used, when Kind is "prometheusrule" (zero value for - // every other kind). The delete dispatch uses this to only call the - // endpoint(s) the CRD used, instead of blind-deleting from both and - // tolerating a 404 from whichever wasn't used — which would otherwise - // silently delete an unrelated asset that happens to share the same - // identifier on the endpoint the CRD never used. - PrometheusRuleEndpoints PrometheusRuleEndpoints // SpamFilterUsesOrigin records whether the deleted spam filter carried a // dash0.com/origin label, when Kind is "spamfilter" (meaningless for // every other kind). false means the filter was identified by @@ -73,11 +65,10 @@ func Diff(before, after Snapshot) DeletionPlan { continue } plan.ByIdentifier = append(plan.ByIdentifier, Deletion{ - Kind: key.Kind, - Identifier: key.Identifier, - Path: path, - PrometheusRuleEndpoints: before.PrometheusRuleEndpointsByIdentifier[key.Identifier], - SpamFilterUsesOrigin: before.SpamFilterUsesOriginByIdentifier[key.Identifier], + Kind: key.Kind, + Identifier: key.Identifier, + Path: path, + SpamFilterUsesOrigin: before.SpamFilterUsesOriginByIdentifier[key.Identifier], }) } sort.Slice(plan.ByIdentifier, func(i, j int) bool { diff --git a/internal/git/snapshot.go b/internal/git/snapshot.go index d3705b1b..71f317a7 100644 --- a/internal/git/snapshot.go +++ b/internal/git/snapshot.go @@ -9,8 +9,8 @@ import ( "os" "path/filepath" - "github.com/dash0hq/dash0-cli/internal/asset" dash0yaml "github.com/dash0hq/dash0-api-client-go/yaml" + "github.com/dash0hq/dash0-cli/internal/asset" "gopkg.in/yaml.v3" ) @@ -54,13 +54,6 @@ type Snapshot struct { // alerting rule removed from a CRD that otherwise still exists. PrometheusAlertsByIdentifier map[string][]dash0yaml.PrometheusAlertName - // PrometheusRuleEndpointsByIdentifier maps a PrometheusRule CRD's - // identifier to which Dash0 endpoint(s) it actually uses. Diff carries - // this into Deletion for a whole-CRD deletion, so the delete dispatch - // only calls the endpoint(s) the CRD used — never blind-deleting from - // the other endpoint just because it also happens to 404-tolerate. - PrometheusRuleEndpointsByIdentifier map[string]PrometheusRuleEndpoints - // SpamFilterUsesOriginByIdentifier maps a spam filter's identifier to // whether it carries a dash0.com/origin label (per // asset.SpamFilterUsesOrigin). Diff carries this into Deletion so --since @@ -74,21 +67,13 @@ type Snapshot struct { Paths map[string]bool } -// PrometheusRuleEndpoints records which Dash0 endpoint(s) a PrometheusRule -// CRD uses, per internal/asset.PrometheusRuleEndpoints. -type PrometheusRuleEndpoints struct { - HasAlerts bool - HasRecords bool -} - func newSnapshot() Snapshot { return Snapshot{ - Identifiers: map[IdentifierKey]string{}, - NoIdentifier: map[string]NoIdentifierDoc{}, - PrometheusAlertsByIdentifier: map[string][]dash0yaml.PrometheusAlertName{}, - PrometheusRuleEndpointsByIdentifier: map[string]PrometheusRuleEndpoints{}, - SpamFilterUsesOriginByIdentifier: map[string]bool{}, - Paths: map[string]bool{}, + Identifiers: map[IdentifierKey]string{}, + NoIdentifier: map[string]NoIdentifierDoc{}, + PrometheusAlertsByIdentifier: map[string][]dash0yaml.PrometheusAlertName{}, + SpamFilterUsesOriginByIdentifier: map[string]bool{}, + Paths: map[string]bool{}, } } @@ -276,12 +261,6 @@ func ingestDocuments(snap *Snapshot, path string, data []byte) error { return fmt.Errorf("failed to extract alert names: %w", err) } snap.PrometheusAlertsByIdentifier[identifier] = alerts - - hasAlerts, hasRecords, err := asset.PrometheusRuleEndpoints(docBytes) - if err != nil { - return fmt.Errorf("failed to determine PrometheusRule endpoints: %w", err) - } - snap.PrometheusRuleEndpointsByIdentifier[identifier] = PrometheusRuleEndpoints{HasAlerts: hasAlerts, HasRecords: hasRecords} } if normalizedKind == "spamfilter" { From 5eebd1442b04a119e8c3a6f78aa65fda6e4a284d Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 24 Aug 2026 15:58:55 +0200 Subject: [PATCH 15/42] fix(apply): delete a PrometheusRule CRD's recording rule when its last record is removed A PrometheusRule CRD that keeps at least one alerting rule but drops its last recording rule survives with the same identifier, so --since never saw it as a whole-CRD deletion -- and applyPrometheusRule simply stops calling ImportRecordingRule once the CRD has zero records left, so the recording rule created earlier was never touched at all. Dash0 ended up permanently out of sync with git while --since reported "no deletions" and exited 0: a false all-clear on a state that no longer matched. Recording rules get the same treatment alerting rules already had: Diff now tracks recording-rule presence per identifier (PrometheusRecordingRoleByIdentifier, populated via the new asset.PrometheusRuleHasRecordingRule) and reports a true -> false transition on a surviving CRD as a "recordingrule"-kind deletion, dispatched through the same confirm-then-delete path as everything else in the plan. Unlike alerting rules, this is a coarse presence/absence signal rather than a per-item diff: Dash0 models a CRD's recording rules as one server-side resource, not one per `record:` entry, so there is no per-record identity to track the way AlertsByName tracks alerts by composed name. Inverts the existing prometheus-recording-partial-removal fixture and its unit/integration/e2e tests, whose prior documented behavior ("plain update, not a deletion: there is no per-record identity to diff") was the bug this fixes -- the "no identity to diff" reasoning only ever justified skipping a per-record diff, not skipping deletion of the recording-rule role entirely once it drops to zero. --- docs/commands.md | 9 +++ docs/testing.md | 2 +- internal/apply/since.go | 17 +++++- internal/apply/since_integration_test.go | 33 +++++++--- internal/asset/prometheusrule.go | 29 +++++++++ internal/git/diff.go | 27 ++++++++ internal/git/diff_test.go | 61 +++++++++++++++++++ internal/git/snapshot.go | 29 +++++++-- internal/skill/content/references/apply.md | 9 +++ .../prometheus-recording-partial-removal.yml | 8 ++- test/e2e/since_e2e_test.go | 27 +++++--- 11 files changed, 225 insertions(+), 26 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 9beac612..7c557274 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1038,6 +1038,15 @@ alerts.yaml: PrometheusRule "service-alerts" (c3d4e5f6-...) created Check rule "service-alerts - HighLatency" deleted ``` +Recording rules get the symmetric treatment, but at CRD granularity rather than per-record: Dash0 models a CRD's recording rules as a single server-side resource, not one per `record:` entry, so there is no per-record id to delete by the way there is a per-alert composed name. +Removing the last `record:` entry from a CRD that keeps at least one `alert:` entry (so the CRD's own identifier survives) is detected as a deletion of that recording rule, even though the surviving alerting rule is only ever a plain update: + +```bash +$ dash0 --experimental apply -f rules/ --since HEAD~1 --force +rules.yaml: PrometheusRule "app-rules" (c3d4e5f6-...) updated +Recording rule "app-rules" (c3d4e5f6-...) deleted +``` + When every asset definition under `-f`'s target has been deleted, `--since` still detects and reports every one of them, rather than failing outright. This holds whether the target directory survives (now empty of `.yaml`/`.yml` files) or was removed entirely along with its files (e.g. `rm -rf dashboards/`) — both count as "nothing currently there," so every asset found at `` becomes a deletion candidate: diff --git a/docs/testing.md b/docs/testing.md index 593313d3..6374a0e5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -113,7 +113,7 @@ The e2e harness does the same (`git config --global --add safe.directory '*'` in - `directory-rename` — a file moves from one subdirectory to another between the ref and HEAD; deletion detection is by identifier, never by file path, so this must be a plain update, not a deletion. - `multi-document-partial-deletion` — one document is removed from a multi-document YAML file; the file survives. - `prometheus-alert-partial-deletion` — one alerting rule is removed from a `PrometheusRule` CRD; the CRD (and its shared `dash0.com/id`) survives. -- `prometheus-recording-partial-removal` — the same shape for a recording rule; the correct behavior is a plain update, not a deletion (there is no per-record identity to diff). +- `prometheus-recording-partial-removal` — the same shape for a recording rule; the alert is a plain update, and the recording rule the CRD no longer declares is deleted despite the CRD's own identifier surviving (a coarse presence/absence signal, since there is no per-record identity to diff). - `first-push-new-branch` — a minimal one-commit repo paired with the literal all-zeros SHA as the ref, simulating a branch's first push. - `non-ancestor-force-push` — a commit is orphaned by a simulated force-push (`git reset --hard` + a new commit); it still resolves by SHA but is not an ancestor of HEAD. - `too-shallow-clone` — the checked-in fixture carries full history; a `--depth 1` clone (performed by the test itself, via `file://`, not baked into the zip) makes the older ref unresolvable. diff --git a/internal/apply/since.go b/internal/apply/since.go index 0d37bc50..d69f483b 100644 --- a/internal/apply/since.go +++ b/internal/apply/since.go @@ -260,7 +260,16 @@ func applyDeletions(ctx context.Context, apiClient dash0api.Client, dataset *str if d.Kind == "spamfilter" && !d.SpamFilterUsesOrigin { fmt.Fprintf(os.Stderr, "warning: spam filter %s was identified by dash0.com/id alone; its live id may have been reassigned by the server since this identifier was recorded (see docs/commands.md's asset-identifiers section), so this delete may miss the actual live filter\n", display) } - prompt := fmt.Sprintf("Are you sure you want to delete %s %s, removed since --since ref? [y/N]: ", displayKind, display) + var prompt string + if d.Kind == "recordingrule" { + // The surviving PrometheusRule CRD's own file is not what's + // being removed here -- only its recording-rule role. The + // generic "removed since --since ref" phrasing below would + // wrongly suggest the whole document is gone. + prompt = fmt.Sprintf("Are you sure you want to delete %s %s, whose last record was removed from its PrometheusRule since --since ref? [y/N]: ", displayKind, display) + } else { + prompt = fmt.Sprintf("Are you sure you want to delete %s %s, removed since --since ref? [y/N]: ", displayKind, display) + } confirmed, err := confirmation.ConfirmDestructiveOperation(ctx, prompt, force) if err != nil { return declined, err @@ -315,6 +324,12 @@ func deleteAssetByKindAndIdentifier(ctx context.Context, apiClient dash0api.Clie err = apiClient.DeleteCheckRule(ctx, identifier, dataset) case "syntheticcheck": err = apiClient.DeleteSyntheticCheck(ctx, identifier, dataset) + case "recordingrule": + // A surviving PrometheusRule CRD whose recording-rule role + // disappeared entirely (see Diff's PrometheusRecordingRoleByIdentifier + // handling) -- distinct from the whole-CRD "prometheusrule" case + // above, which already attempts this endpoint unconditionally. + err = apiClient.DeleteRecordingRule(ctx, identifier, dataset) case "view": err = apiClient.DeleteView(ctx, identifier, dataset) case "spamfilter": diff --git a/internal/apply/since_integration_test.go b/internal/apply/since_integration_test.go index 51a37e67..113127f7 100644 --- a/internal/apply/since_integration_test.go +++ b/internal/apply/since_integration_test.go @@ -695,7 +695,19 @@ spec: require.NotNil(t, deleteReq, "expected a DELETE request for the view document removed from the surviving file") } -func TestApply_Since_PrometheusRecordingRulePartialRemovalIsNotADeletion(t *testing.T) { +// TestApply_Since_PrometheusRecordingRulePartialRemovalDeletesRecordingRule +// is a regression test for a bug where a PrometheusRule CRD losing its last +// recording rule (while an alert kept the CRD's identifier alive) produced +// no deletion at all: applyPrometheusRule simply stops calling +// ImportRecordingRule once the CRD has zero records left, so the recording +// rule created back when the file was still mixed was left stale in Dash0 +// forever, and --since reported "no deletions" -- a false all-clear on a +// state that no longer matched git. The fix tracks recording-rule presence +// per identifier and treats a true -> false transition on a surviving CRD +// as a deletion of that recording rule -- a coarse presence/absence signal +// rather than a per-record diff, since Dash0 models a CRD's recording +// rules as a single server-side resource, not one per record. +func TestApply_Since_PrometheusRecordingRulePartialRemovalDeletesRecordingRule(t *testing.T) { testutil.SetupTestEnv(t) const ruleID = "f47ac10b-58cc-4372-a567-0e02b2c3d479" @@ -727,8 +739,8 @@ spec: before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) // Remove the recording rule; the alert (and the CRD's shared identifier) - // survives. This is not tracked as a deletion at all — no per-record - // identity exists to diff, so it is a plain update to the surviving CRD. + // survives, so it is a plain update -- but the recording rule the CRD + // no longer declares must still be deleted. writeFileFixture(t, dir, "rules.yaml", `apiVersion: monitoring.coreos.com/v1 kind: PrometheusRule metadata: @@ -752,8 +764,11 @@ spec: BodyFile: testutil.FixtureCheckRulesNotFound, }) server.WithCheckRulesUpdate(testutil.FixtureCheckRulesImportSuccess) - // No recording-rules route registered at all: the removed record entry - // must never trigger a call to that endpoint, delete or otherwise. + server.OnPattern(http.MethodDelete, recordingRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) cmd := newSinceTestCmd() cmd.SetArgs([]string{ @@ -762,12 +777,16 @@ spec: }) var cmdErr error - testutil.CaptureStdout(t, func() { + output := testutil.CaptureStdout(t, func() { cmdErr = cmd.Execute() }) require.NoError(t, cmdErr) - assert.Nil(t, findRequest(server.Requests(), http.MethodDelete, apiPathRecordingRules+"/"+ruleID), "removing a record entry from a surviving CRD must not be treated as a deletion") + assert.Contains(t, output, "Recording rule") + assert.Contains(t, output, ruleID) + assert.Contains(t, output, "deleted") + + require.NotNil(t, findRequest(server.Requests(), http.MethodDelete, apiPathRecordingRules+"/"+ruleID), "expected the dropped recording rule to be deleted") require.NotNil(t, findRequest(server.Requests(), http.MethodPut, apiPathCheckRules+"/"+ruleID), "the surviving alert must still go through the ordinary update path") } diff --git a/internal/asset/prometheusrule.go b/internal/asset/prometheusrule.go index 9cacf046..4fcc52a3 100644 --- a/internal/asset/prometheusrule.go +++ b/internal/asset/prometheusrule.go @@ -7,6 +7,7 @@ import ( dash0api "github.com/dash0hq/dash0-api-client-go" dash0yaml "github.com/dash0hq/dash0-api-client-go/yaml" "gopkg.in/yaml.v3" + sigsyaml "sigs.k8s.io/yaml" ) // ParseCheckRules parses a CheckRule or PrometheusRule CRD document into one or @@ -30,6 +31,34 @@ func ParseCheckRules(data []byte) ([]*dash0api.PrometheusAlertRule, error) { return rules, nil } +// PrometheusRuleHasRecordingRule reports whether a PrometheusRule CRD +// document has at least one recording rule (a `record:` entry). Returns +// false for a document that isn't a PrometheusRule CRD at all. +// +// --since uses this as a coarse presence/absence signal to detect a CRD +// that survives (its own identifier is still present in both snapshots) but +// whose recording-rule role disappeared entirely -- e.g. its last `record:` +// entry was removed while an `alert:` entry keeps the CRD's identifier +// alive. Unlike alerting rules, which become one check rule per alert (and +// so can be tracked and deleted individually by name), Dash0 models a CRD's +// recording rules as a single server-side resource, so there is no +// per-record identity to track -- only whether the role exists at all. +func PrometheusRuleHasRecordingRule(data []byte) (bool, error) { + kind, err := dash0yaml.DetectKind(data) + if err != nil { + return false, err + } + if !strings.EqualFold(kind, "PrometheusRule") { + return false, nil + } + + var crd dash0api.RecordingRule + if err := sigsyaml.Unmarshal(data, &crd); err != nil { + return false, fmt.Errorf("failed to parse PrometheusRule: %w", err) + } + return RecordingOnlyPrometheusRule(&crd) != nil, nil +} + // composePrometheusRuleNames rewrites the name of each check rule produced from // a PrometheusRule CRD to " - ". It is a no-op for // plain CheckRule documents. diff --git a/internal/git/diff.go b/internal/git/diff.go index 1048f6f8..5b56c6c6 100644 --- a/internal/git/diff.go +++ b/internal/git/diff.go @@ -71,6 +71,33 @@ func Diff(before, after Snapshot) DeletionPlan { SpamFilterUsesOrigin: before.SpamFilterUsesOriginByIdentifier[key.Identifier], }) } + + // A PrometheusRule CRD that survives (its own identifier is present in + // both snapshots) can still lose its recording-rule role entirely -- + // e.g. its last `record:` entry removed while an `alert:` entry keeps + // the CRD's identifier alive. Unlike alerting rules, recording rules + // have no per-item identity to diff by (Dash0 models a CRD's recording + // rules as one server-side resource), so this is a coarse + // presence/absence check rather than a name-based diff like AlertsByName + // below. A CRD whose identifier disappeared entirely is skipped here: + // deletePrometheusRuleCRD already attempts the recording-rules endpoint + // unconditionally for a whole-CRD deletion, so adding a second entry + // for the same identifier would just double-delete it. + for identifier, hadRecordingRule := range before.PrometheusRecordingRoleByIdentifier { + if !hadRecordingRule { + continue + } + hasRecordingRuleNow, crdSurvives := after.PrometheusRecordingRoleByIdentifier[identifier] + if !crdSurvives || hasRecordingRuleNow { + continue + } + plan.ByIdentifier = append(plan.ByIdentifier, Deletion{ + Kind: "recordingrule", + Identifier: identifier, + Path: before.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: identifier}], + }) + } + sort.Slice(plan.ByIdentifier, func(i, j int) bool { if plan.ByIdentifier[i].Kind != plan.ByIdentifier[j].Kind { return plan.ByIdentifier[i].Kind < plan.ByIdentifier[j].Kind diff --git a/internal/git/diff_test.go b/internal/git/diff_test.go index d36c3635..79cffcec 100644 --- a/internal/git/diff_test.go +++ b/internal/git/diff_test.go @@ -55,6 +55,67 @@ func TestDiff_PrometheusAlertPartialRemoval(t *testing.T) { assert.Equal(t, "g - B", plan.AlertsByName[0].CheckRuleName()) } +// TestDiff_PrometheusRecordingRoleDroppedWhileCRDSurvives is a regression +// test for a bug where a PrometheusRule CRD losing its last recording rule +// (while an alerting rule kept the CRD's identifier alive) produced no +// deletion signal at all: applyPrometheusRule simply stops calling +// ImportRecordingRule once RecordingOnlyPrometheusRule returns nil, so the +// recording rule created back when the CRD still had a record is left +// stale in Dash0 forever, and --since reported "no deletions" -- a false +// all-clear on a state that no longer matches git. Unlike alerting rules +// (tracked per-alert by name via PrometheusAlertsByIdentifier/AlertsByName), +// recording rules have no per-item identity, so this is a coarse +// presence/absence signal, surfaced as a "recordingrule"-kind entry in +// ByIdentifier rather than a new AlertsByName-shaped slice. +func TestDiff_PrometheusRecordingRoleDroppedWhileCRDSurvives(t *testing.T) { + before := newSnapshot() + before.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules.yaml" + before.PrometheusRecordingRoleByIdentifier["crd-1"] = true + + after := newSnapshot() + after.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules.yaml" + after.PrometheusRecordingRoleByIdentifier["crd-1"] = false + + plan := Diff(before, after) + require.Len(t, plan.ByIdentifier, 1) + assert.Equal(t, Deletion{Kind: "recordingrule", Identifier: "crd-1", Path: "rules.yaml"}, plan.ByIdentifier[0]) +} + +// TestDiff_PrometheusRecordingRoleSurvivesIsNotADeletion pins the negative +// case: a CRD that still has a recording rule in both snapshots must not +// produce any "recordingrule" deletion entry. +func TestDiff_PrometheusRecordingRoleSurvivesIsNotADeletion(t *testing.T) { + before := newSnapshot() + before.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules.yaml" + before.PrometheusRecordingRoleByIdentifier["crd-1"] = true + + after := newSnapshot() + after.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules.yaml" + after.PrometheusRecordingRoleByIdentifier["crd-1"] = true + + plan := Diff(before, after) + assert.True(t, plan.IsEmpty()) +} + +// TestDiff_PrometheusWholeCRDDeletionSkipsRecordingRoleCheck is a regression +// test for a bug where a whole-CRD deletion (identifier gone entirely, not +// just its recording role) would double-report the recording rule: once as +// the "prometheusrule"-kind whole-CRD entry (whose dispatch, +// deletePrometheusRuleCRD, already attempts DeleteRecordingRule +// unconditionally) and again as a standalone "recordingrule"-kind entry, +// which would call DeleteRecordingRule a second, redundant time. +func TestDiff_PrometheusWholeCRDDeletionSkipsRecordingRoleCheck(t *testing.T) { + before := newSnapshot() + before.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules.yaml" + before.PrometheusRecordingRoleByIdentifier["crd-1"] = true + + after := newSnapshot() + + plan := Diff(before, after) + require.Len(t, plan.ByIdentifier, 1) + assert.Equal(t, "prometheusrule", plan.ByIdentifier[0].Kind) +} + func TestDiff_PrometheusWholeCRDDeletionSkipsAlertCheck(t *testing.T) { before := newSnapshot() before.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules.yaml" diff --git a/internal/git/snapshot.go b/internal/git/snapshot.go index 71f317a7..597d4323 100644 --- a/internal/git/snapshot.go +++ b/internal/git/snapshot.go @@ -54,6 +54,18 @@ type Snapshot struct { // alerting rule removed from a CRD that otherwise still exists. PrometheusAlertsByIdentifier map[string][]dash0yaml.PrometheusAlertName + // PrometheusRecordingRoleByIdentifier maps a PrometheusRule CRD's + // identifier to whether it has at least one recording rule. Recorded for + // every PrometheusRule CRD identifier found, even when false, so Diff + // can tell "this CRD never had a recording role" apart from "this CRD + // doesn't exist in this snapshot at all" -- the same map-presence + // pattern PrometheusAlertsByIdentifier already relies on. Diff uses this + // to detect a CRD that survives but whose recording-rule role + // disappeared entirely (its last `record:` entry removed), a case a + // per-alert-name diff can't catch: Dash0 models a CRD's recording rules + // as one server-side resource, not one per record. + PrometheusRecordingRoleByIdentifier map[string]bool + // SpamFilterUsesOriginByIdentifier maps a spam filter's identifier to // whether it carries a dash0.com/origin label (per // asset.SpamFilterUsesOrigin). Diff carries this into Deletion so --since @@ -69,11 +81,12 @@ type Snapshot struct { func newSnapshot() Snapshot { return Snapshot{ - Identifiers: map[IdentifierKey]string{}, - NoIdentifier: map[string]NoIdentifierDoc{}, - PrometheusAlertsByIdentifier: map[string][]dash0yaml.PrometheusAlertName{}, - SpamFilterUsesOriginByIdentifier: map[string]bool{}, - Paths: map[string]bool{}, + Identifiers: map[IdentifierKey]string{}, + NoIdentifier: map[string]NoIdentifierDoc{}, + PrometheusAlertsByIdentifier: map[string][]dash0yaml.PrometheusAlertName{}, + PrometheusRecordingRoleByIdentifier: map[string]bool{}, + SpamFilterUsesOriginByIdentifier: map[string]bool{}, + Paths: map[string]bool{}, } } @@ -261,6 +274,12 @@ func ingestDocuments(snap *Snapshot, path string, data []byte) error { return fmt.Errorf("failed to extract alert names: %w", err) } snap.PrometheusAlertsByIdentifier[identifier] = alerts + + hasRecordingRule, err := asset.PrometheusRuleHasRecordingRule(docBytes) + if err != nil { + return fmt.Errorf("failed to determine recording rule presence: %w", err) + } + snap.PrometheusRecordingRoleByIdentifier[identifier] = hasRecordingRule } if normalizedKind == "spamfilter" { diff --git a/internal/skill/content/references/apply.md b/internal/skill/content/references/apply.md index 99ac21e1..cecf9376 100644 --- a/internal/skill/content/references/apply.md +++ b/internal/skill/content/references/apply.md @@ -170,6 +170,15 @@ alerts.yaml: PrometheusRule "service-alerts" (c3d4e5f6-...) created Check rule "service-alerts - HighLatency" deleted ``` +Recording rules get the symmetric treatment, but at CRD granularity rather than per-record: Dash0 models a CRD's recording rules as a single server-side resource, not one per `record:` entry, so there is no per-record id to delete by the way there is a per-alert composed name. +Removing the last `record:` entry from a CRD that keeps at least one `alert:` entry (so the CRD's own identifier survives) is detected as a deletion of that recording rule, even though the surviving alerting rule is only ever a plain update: + +```bash +$ dash0 --experimental apply -f rules/ --since HEAD~1 --force +rules.yaml: PrometheusRule "app-rules" (c3d4e5f6-...) updated +Recording rule "app-rules" (c3d4e5f6-...) deleted +``` + When every asset definition under `-f`'s target has been deleted, `--since` still detects and reports every one of them, rather than failing outright. This holds whether the target directory survives (now empty of `.yaml`/`.yml` files) or was removed entirely along with its files (e.g. `rm -rf dashboards/`) — both count as "nothing currently there," so every asset found at `` becomes a deletion candidate: diff --git a/internal/testutil/fixtures/git-scenarios/prometheus-recording-partial-removal.yml b/internal/testutil/fixtures/git-scenarios/prometheus-recording-partial-removal.yml index 90373c70..ce5e1270 100644 --- a/internal/testutil/fixtures/git-scenarios/prometheus-recording-partial-removal.yml +++ b/internal/testutil/fixtures/git-scenarios/prometheus-recording-partial-removal.yml @@ -2,9 +2,11 @@ kind: GitRepoFixture spec: # A PrometheusRule CRD with one alerting rule and one recording rule # sharing one dash0.com/id, then a commit removing the recording rule - # while the alert (and the CRD's shared identifier) survives. --since must - # treat this as a plain update, not a deletion: there is no per-record - # identity to diff. + # while the alert (and the CRD's shared identifier) survives. The alert + # is a plain update; the recording rule the CRD no longer declares must + # still be deleted, even though the CRD's own identifier survives -- a + # coarse presence/absence signal, since there is no per-record identity + # to diff. sinceRef: before repo: commits: diff --git a/test/e2e/since_e2e_test.go b/test/e2e/since_e2e_test.go index fa0edb61..754f2615 100644 --- a/test/e2e/since_e2e_test.go +++ b/test/e2e/since_e2e_test.go @@ -23,9 +23,10 @@ const ( ) var ( - dashboardIDPattern = regexp.MustCompile(`^/api/dashboards/[^/]+$`) - checkRuleIDPattern = regexp.MustCompile(`^/api/alerting/check-rules/[^/]+$`) - viewIDPattern = regexp.MustCompile(`^/api/views/[^/]+$`) + dashboardIDPattern = regexp.MustCompile(`^/api/dashboards/[^/]+$`) + checkRuleIDPattern = regexp.MustCompile(`^/api/alerting/check-rules/[^/]+$`) + viewIDPattern = regexp.MustCompile(`^/api/views/[^/]+$`) + recordingRuleIDPattern = regexp.MustCompile(`^/api/recording-rules/[^/]+$`) ) // drainExecOutput reads an exec result reader to completion. testcontainers' @@ -229,16 +230,21 @@ func TestE2E_ApplySince_PrometheusAlertPartialDeletion(t *testing.T) { } } -func TestE2E_ApplySince_PrometheusRecordingPartialRemovalIsNotADeletion(t *testing.T) { +// TestE2E_ApplySince_PrometheusRecordingRoleDroppedWhileCRDSurvives is a +// regression test for a bug where a PrometheusRule CRD losing its last +// recording rule (while an alert kept the CRD's identifier alive) produced +// no deletion at all, leaving the recording rule stale in Dash0 while +// --since reported a false "no deletions" all-clear. The surviving alert +// is updated (not deleted); the recording rule the CRD no longer declares +// is deleted. +func TestE2E_ApplySince_PrometheusRecordingRoleDroppedWhileCRDSurvives(t *testing.T) { ctx := context.Background() repoDir, ref := testutil.BuildGitScenario(t, "prometheus-recording-partial-removal") server := testutil.NewMockServer(t, testutil.FixturesDir()) server.OnPattern(http.MethodGet, checkRuleIDPattern, testutil.MockResponse{StatusCode: http.StatusNotFound, Body: map[string]any{}}) server.OnPattern(http.MethodPut, checkRuleIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, BodyFile: testutil.FixtureCheckRulesImportSuccess}) - // Deliberately no recording-rules route: hitting one would 404 through - // the mock server's default handler, which the exit-code check below - // would surface as a failure. + server.OnPattern(http.MethodDelete, recordingRuleIDPattern, testutil.MockResponse{StatusCode: http.StatusOK, Body: map[string]any{}}) container := startContainer(ctx, t, mockServerPort(t, server)) copyScenarioIntoContainer(ctx, t, container, repoDir) @@ -249,8 +255,11 @@ func TestE2E_ApplySince_PrometheusRecordingPartialRemovalIsNotADeletion(t *testi if exitCode != 0 { t.Fatalf("expected exit 0, got %d. Output:\n%s", exitCode, output) } - if strings.Contains(output, "recording") { - t.Errorf("removing a record entry from a surviving CRD must not be treated as a deletion, got:\n%s", output) + if !strings.Contains(output, "Recording rule") { + t.Errorf("expected output to mention the deleted recording rule, got:\n%s", output) + } + if !strings.Contains(output, "deleted") { + t.Errorf("expected output to mention a deletion, got:\n%s", output) } } From 0e18638e156951edbdfbbebbfb7f6c15886a5f13 Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 24 Aug 2026 16:07:37 +0200 Subject: [PATCH 16/42] fix(apply): tolerate a concurrently-deleted asset during --since without --force A 404 while --since was deleting a planned asset only counted as "already deleted" (and the run kept going) when --force was passed -- otherwise it hard-failed the entire run, even though the asset being gone already IS the state --since is trying to reach. This coupled two unrelated decisions into one flag: whether to skip the confirmation prompt, and whether a concurrent deletion (e.g. someone removing the asset directly in the Dash0 UI between --since's plan and its own delete call) is tolerable. A standalone ` delete --force` reasonably ties these together, since it acts on one asset the caller named by hand; --since reconciles a whole scanned scope, where one already-gone asset shouldn't fail every other deletion (and every earlier create/update) in the same run. deleteAssetByKindAndIdentifier, deletePrometheusRuleCRD, and deleteCheckRuleByName no longer take a force parameter for this purpose: a 404 (or, for deleteCheckRuleByName, not finding the check rule by name at all) is now always treated as already-deleted, independent of --force. --force keeps its existing, separate job in applyDeletions of skipping the confirmation prompt itself. --- docs/commands.md | 13 ++++ internal/apply/since.go | 48 ++++++++++----- internal/apply/since_integration_test.go | 70 ++++++++++++++++++++++ internal/skill/content/references/apply.md | 13 ++++ 4 files changed, 128 insertions(+), 16 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 7c557274..dfa442f1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -993,6 +993,19 @@ Skip the confirmation prompt (for CI/CD and agent-driven pipelines, where there dash0 --experimental apply -f dashboards/ --since HEAD~1 --force ``` +If an asset `--since` planned to delete is already gone by the time the confirmed deletion runs — someone deleted it directly in the Dash0 UI, for example — that is treated as the desired end state already reached, regardless of `--force`. +This is unconditional (unlike a standalone ` delete`, whose own `--force` flag gates the same idempotent-404 tolerance): `--since`'s job is reconciling Dash0 to match git across a whole scanned scope, and one asset a concurrent change already removed matching that goal is not a reason to fail the run, confirmed or not. +`--force` still controls only whether the confirmation prompt itself is skipped: + +```bash +$ dash0 --experimental apply -f dashboards/ --since HEAD~1 +keep.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created +Are you sure you want to delete Dashboard "Old Dashboard" (b2c3d4e5-...), removed since --since ref? [y/N]: y +Dashboard "Old Dashboard" was already deleted +$ echo $? +0 +``` + Declining a deletion does not stop the rest of the run — creates and updates for the surviving documents still go through — but the command exits non-zero, since the sync's desired end state ("this asset is gone, matching git") was not reached: ```bash diff --git a/internal/apply/since.go b/internal/apply/since.go index d69f483b..7dfe858e 100644 --- a/internal/apply/since.go +++ b/internal/apply/since.go @@ -279,7 +279,7 @@ func applyDeletions(ctx context.Context, apiClient dash0api.Client, dataset *str declined++ continue } - if err := deleteAssetByKindAndIdentifier(ctx, apiClient, dataset, d, force); err != nil { + if err := deleteAssetByKindAndIdentifier(ctx, apiClient, dataset, d); err != nil { return declined, fmt.Errorf("failed to delete %s %s: %w", displayKind, display, err) } fmt.Printf("%s %s deleted\n", displayKind, display) @@ -297,7 +297,7 @@ func applyDeletions(ctx context.Context, apiClient dash0api.Client, dataset *str declined++ continue } - if err := deleteCheckRuleByName(ctx, apiClient, dataset, name, force); err != nil { + if err := deleteCheckRuleByName(ctx, apiClient, dataset, name); err != nil { return declined, fmt.Errorf("failed to delete check rule %q: %w", name, err) } fmt.Printf("Check rule %q deleted\n", name) @@ -310,10 +310,21 @@ func applyDeletions(ctx context.Context, apiClient dash0api.Client, dataset *str // asset whose identifier disappeared entirely) to the matching per-kind // delete API call, mirroring the dispatch applyDocument already uses for // create/update. -func deleteAssetByKindAndIdentifier(ctx context.Context, apiClient dash0api.Client, dataset *string, d gitutil.Deletion, force bool) error { +// +// A 404 here always means "already gone" and is always tolerated, +// regardless of --force: --since's deletion phase is reconciling Dash0 to +// match git, and an asset that's already absent already matches the +// desired end state, whether or not the caller passed --force. --force +// keeps its own, separate job of skipping the confirmation prompt in +// applyDeletions, before this function is ever called. This is +// deliberately unlike the force-gated tolerance a standalone ` delete` +// command uses: that command acts on one asset the caller named by hand, so +// a 404 without --force there is more likely a typo'd id worth surfacing +// loudly than a benign race. +func deleteAssetByKindAndIdentifier(ctx context.Context, apiClient dash0api.Client, dataset *string, d gitutil.Deletion) error { kind, identifier := d.Kind, d.Identifier if kind == "prometheusrule" { - return deletePrometheusRuleCRD(ctx, apiClient, dataset, identifier, force) + return deletePrometheusRuleCRD(ctx, apiClient, dataset, identifier) } var err error @@ -344,7 +355,7 @@ func deleteAssetByKindAndIdentifier(ctx context.Context, apiClient dash0api.Clie ectx := client.ErrorContext{AssetType: asset.KindDisplayName(kind), AssetID: identifier} if err != nil { - if client.IsAlreadyDeleted(err, force, ectx) { + if client.IsAlreadyDeleted(err, true, ectx) { return nil } return client.HandleAPIError(err, ectx) @@ -373,7 +384,11 @@ func deleteAssetByKindAndIdentifier(ctx context.Context, apiClient dash0api.Clie // happen to share the same identifier by coincidence (not because they came // from the same CRD) would both be deleted together — accepted as an edge // case narrow enough not to justify leaving real orphaned assets behind. -func deletePrometheusRuleCRD(ctx context.Context, apiClient dash0api.Client, dataset *string, identifier string, force bool) error { +// +// A 404 on either endpoint always means "already gone" (see +// deleteAssetByKindAndIdentifier's doc comment for why this is unconditional, +// unlike a standalone ` delete` command). +func deletePrometheusRuleCRD(ctx context.Context, apiClient dash0api.Client, dataset *string, identifier string) error { checkRuleErr := apiClient.DeleteCheckRule(ctx, identifier, dataset) recordingRuleErr := apiClient.DeleteRecordingRule(ctx, identifier, dataset) @@ -382,14 +397,14 @@ func deletePrometheusRuleCRD(ctx context.Context, apiClient dash0api.Client, dat if checkRuleErr != nil && !checkRuleNotFound { ectx := client.ErrorContext{AssetType: "check rule", AssetID: identifier} - if client.IsAlreadyDeleted(checkRuleErr, force, ectx) { + if client.IsAlreadyDeleted(checkRuleErr, true, ectx) { return nil } return client.HandleAPIError(checkRuleErr, ectx) } if recordingRuleErr != nil && !recordingRuleNotFound { ectx := client.ErrorContext{AssetType: "recording rule", AssetID: identifier} - if client.IsAlreadyDeleted(recordingRuleErr, force, ectx) { + if client.IsAlreadyDeleted(recordingRuleErr, true, ectx) { return nil } return client.HandleAPIError(recordingRuleErr, ectx) @@ -399,7 +414,7 @@ func deletePrometheusRuleCRD(ctx context.Context, apiClient dash0api.Client, dat // for this identifier. if checkRuleNotFound && recordingRuleNotFound { ectx := client.ErrorContext{AssetType: "PrometheusRule", AssetID: identifier} - if client.IsAlreadyDeleted(checkRuleErr, force, ectx) { + if client.IsAlreadyDeleted(checkRuleErr, true, ectx) { return nil } return client.HandleAPIError(checkRuleErr, ectx) @@ -412,23 +427,24 @@ func deletePrometheusRuleCRD(ctx context.Context, apiClient dash0api.Client, dat // it. This is the only way to target a single alerting rule removed from a // PrometheusRule CRD that otherwise survives: the CRD's shared identifier // can't distinguish between the alerts it contains. -func deleteCheckRuleByName(ctx context.Context, apiClient dash0api.Client, dataset *string, name string, force bool) error { +// +// Not finding it by name at all, or a 404 on the delete itself, always means +// "already gone" (see deleteAssetByKindAndIdentifier's doc comment for why +// this is unconditional, unlike a standalone ` delete` command). +func deleteCheckRuleByName(ctx context.Context, apiClient dash0api.Client, dataset *string, name string) error { id, err := findCheckRuleIDByName(ctx, apiClient, dataset, name) if err != nil { return err } if id == "" { - if force { - fmt.Fprintf(os.Stderr, "Check rule %q was already deleted\n", name) - return nil - } - return fmt.Errorf("check rule %q not found (already deleted?)", name) + fmt.Fprintf(os.Stderr, "Check rule %q was already deleted\n", name) + return nil } err = apiClient.DeleteCheckRule(ctx, id, dataset) ectx := client.ErrorContext{AssetType: "check rule", AssetID: id, AssetName: name} if err != nil { - if client.IsAlreadyDeleted(err, force, ectx) { + if client.IsAlreadyDeleted(err, true, ectx) { return nil } return client.HandleAPIError(err, ectx) diff --git a/internal/apply/since_integration_test.go b/internal/apply/since_integration_test.go index 113127f7..8ba45e4f 100644 --- a/internal/apply/since_integration_test.go +++ b/internal/apply/since_integration_test.go @@ -80,6 +80,76 @@ spec: assert.Contains(t, output, "deleted") } +// TestApply_Since_ConcurrentlyDeletedAssetIsToleratedWithoutForce is a +// regression test for a bug where an asset already deleted by someone else +// (e.g. via the Dash0 UI) before --since's own delete call ran caused the +// whole run to fail with a raw 404 error unless --force was passed -- +// coupling "tolerate an asset that's already gone" to "skip every +// confirmation prompt" as if they were the same decision. They aren't: +// --since's job is reconciling Dash0 to match git, and a 404 on a planned +// deletion already IS that match, confirmed or not. This asserts the +// confirmation prompt still fires (unlike --force, which skips it), but a +// 404 on the delete itself is tolerated exactly as it would be with +// --force. +func TestApply_Since_ConcurrentlyDeletedAssetIsToleratedWithoutForce(t *testing.T) { + testutil.SetupTestEnv(t) + + dir := t.TempDir() + runGitCmd(t, dir, "init", "-q", "-b", "main") + runGitCmd(t, dir, "config", "user.email", "test@example.com") + runGitCmd(t, dir, "config", "user.name", "Test") + runGitCmd(t, dir, "config", "commit.gpgsign", "false") + + writeFileFixture(t, dir, "dashboard.yaml", `apiVersion: dash0.com/v1alpha1 +kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: a1b2c3d4-5678-90ab-cdef-1234567890ab +spec: + display: + name: My Dashboard +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add dashboard") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + require.NoError(t, os.Remove(filepath.Join(dir, "dashboard.yaml"))) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove dashboard") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + // Simulates someone deleting the dashboard concurrently, e.g. via the + // Dash0 UI, before this run's own delete call. + server.OnPattern(http.MethodDelete, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureDashboardsNotFound, + Validator: testutil.RequireHeaders, + }) + + restore := confirmation.SetReaderForTest(strings.NewReader("y\n")) + defer restore() + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + // Deliberately no --force: the confirmation prompt must still fire. + "-f", dir, "--since", before, "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + var stdout string + stderr := testutil.CaptureStderr(t, func() { + stdout = testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + }) + + require.NoError(t, cmdErr, "a concurrently-deleted asset must not fail the run even without --force") + assert.Contains(t, stdout, "Are you sure you want to delete", "the confirmation prompt must still fire without --force") + assert.Contains(t, stderr, "was already deleted") +} + // TestApply_Since_AllFilesDeleted_DirectorySurvives is a regression test for // a bug where --since found nothing to delete (in fact, failed the whole // run outright) once every asset definition under -f's target had been diff --git a/internal/skill/content/references/apply.md b/internal/skill/content/references/apply.md index cecf9376..668d09df 100644 --- a/internal/skill/content/references/apply.md +++ b/internal/skill/content/references/apply.md @@ -125,6 +125,19 @@ Skip the confirmation prompt (for CI/CD and agent-driven pipelines, where there dash0 --experimental apply -f dashboards/ --since HEAD~1 --force ``` +If an asset `--since` planned to delete is already gone by the time the confirmed deletion runs — someone deleted it directly in the Dash0 UI, for example — that is treated as the desired end state already reached, regardless of `--force`. +This is unconditional (unlike a standalone ` delete`, whose own `--force` flag gates the same idempotent-404 tolerance): `--since`'s job is reconciling Dash0 to match git across a whole scanned scope, and one asset a concurrent change already removed matching that goal is not a reason to fail the run, confirmed or not. +`--force` still controls only whether the confirmation prompt itself is skipped: + +```bash +$ dash0 --experimental apply -f dashboards/ --since HEAD~1 +keep.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created +Are you sure you want to delete Dashboard "Old Dashboard" (b2c3d4e5-...), removed since --since ref? [y/N]: y +Dashboard "Old Dashboard" was already deleted +$ echo $? +0 +``` + Declining a deletion does not stop the rest of the run — creates and updates for the surviving documents still go through — but the command exits non-zero, since the sync's desired end state ("this asset is gone, matching git") was not reached: ```bash From ef5dcf0a79497b500a6a1d2b9bfef8eee7c66006 Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 24 Aug 2026 16:12:57 +0200 Subject: [PATCH 17/42] feat(apply): add --accept-non-ancestor-ref to decouple it from --force --since's non-ancestor-ref warning (printed when the ref resolves but isn't an ancestor of HEAD, e.g. after a force-push) could only be accepted by passing --force -- which also silently skips every per-asset deletion confirmation. These are two separate decisions: accepting a doubtful ref is not the same as wanting the whole run unattended. A CI job pointed at the wrong ref by --force alone had no way to ask a human before deleting anything once past that warning. --accept-non-ancestor-ref answers only the ref-acceptance question, leaving every per-asset deletion prompt in place. --force keeps its existing behavior unchanged (it still implies both, for backward compatibility) since it's the documented way to run --since fully unattended in CI/CD, where there's no terminal to answer any prompt either way. --- docs/commands.md | 17 ++++++- internal/apply/apply.go | 23 +++++++-- internal/apply/since_integration_test.go | 56 ++++++++++++++++++++++ internal/skill/content/references/apply.md | 14 +++++- 4 files changed, 103 insertions(+), 7 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index dfa442f1..4427a18f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -871,7 +871,7 @@ Apply asset definitions from a file, directory, or stdin. If an asset already exists (matched by ID), it is updated; otherwise it is created. ```bash -dash0 apply -f [--dry-run] [--since [--force]] +dash0 apply -f [--dry-run] [--since [--force] [--accept-non-ancestor-ref]] ``` | Flag | Short | Description | @@ -879,7 +879,8 @@ dash0 apply -f [--dry-run] [--since [--force]] | `--file` | `-f` | Path to a YAML/JSON file, a directory, or `-` for stdin | | `--dry-run` | | Validate without applying | | `--since` | | [experimental] Delete assets removed from `-f`'s contents since this git ref (requires `--experimental`/`-X`) | -| `--force` | | Skip the confirmation prompt for deletions triggered by `--since` | +| `--force` | | Skip the confirmation prompt for deletions triggered by `--since`; also accepts a non-ancestor `--since` ref | +| `--accept-non-ancestor-ref` | | Accept a non-ancestor `--since` ref on its own, without also skipping the per-deletion confirmation prompt | For assets that are updated, a unified diff of the changes is shown. Assets that are created show the standard creation message. @@ -1035,6 +1036,18 @@ keep.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created Dashboard "Old Dashboard" (b2c3d4e5-...) deleted ``` +`--force` accepts the doubtful ref and skips every per-asset deletion confirmation together, since it always meant "run unattended" end to end. +These are two separate decisions, though: accepting a ref that might be from a force-push is not the same as wanting no further prompts at all. +`--accept-non-ancestor-ref` answers only the first one, leaving the per-asset confirmation in place: + +```bash +$ dash0 --experimental apply -f dashboards/ --since --accept-non-ancestor-ref +warning: --since '' is not an ancestor of HEAD (likely a force-push or history rewrite); deletion detection may be inaccurate +keep.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created +Are you sure you want to delete Dashboard "Old Dashboard" (b2c3d4e5-...), removed since --since ref? [y/N]: y +Dashboard "Old Dashboard" (b2c3d4e5-...) deleted +``` + A document removed from git history with no `dash0.com/id` or `dash0.com/origin` at `` fails the entire `--since` run before creating, updating, or deleting anything, since there is no reliable way to know which live asset (if any) it corresponds to: ```bash diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 020f484a..d9aa9941 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -41,6 +41,12 @@ type applyFlags struct { // --since never being mentioned at all. SinceFlagSet bool Force bool + // AcceptNonAncestorRef authorizes proceeding past the warning printed + // when --since's ref resolves but is not an ancestor of HEAD (likely a + // force-push or history rewrite), without also implying --force's + // separate job of skipping every per-asset deletion confirmation. + // --force still authorizes this too, for backward compatibility. + AcceptNonAncestorRef bool } // NewApplyCmd creates the top-level apply command @@ -70,7 +76,7 @@ A PrometheusRule CRD that mixes alerting and recording rules is dispatched to bo If an asset exists, it will be updated. If it doesn't exist, it will be created. -[experimental] Pass --since (requires --experimental/-X) to also delete assets whose definition existed at but is no longer present in -f's current contents, detected by identifier (id or origin), never by file path. --force skips the per-deletion confirmation prompt.` + internal.CONFIG_HINT, +[experimental] Pass --since (requires --experimental/-X) to also delete assets whose definition existed at but is no longer present in -f's current contents, detected by identifier (id or origin), never by file path. --force skips the per-deletion confirmation prompt and accepts a non-ancestor --since ref; --accept-non-ancestor-ref accepts a non-ancestor ref on its own, without also skipping the per-deletion prompt.` + internal.CONFIG_HINT, Example: ` # Apply a single asset dash0 apply -f dashboard.yaml @@ -93,7 +99,10 @@ If an asset exists, it will be updated. If it doesn't exist, it will be created. dash0 --experimental apply -f dashboards/ --since HEAD~1 # Same, without the per-deletion confirmation prompt (experimental) - dash0 --experimental apply -f dashboards/ --since HEAD~1 --force`, + dash0 --experimental apply -f dashboards/ --since HEAD~1 --force + + # Accept a --since ref from a force-push without skipping per-deletion confirmation (experimental) + dash0 --experimental apply -f dashboards/ --since HEAD~1 --accept-non-ancestor-ref`, RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { return fmt.Errorf("unexpected arguments: %s\nTo apply multiple files, pass a directory with -f instead of a glob pattern", strings.Join(args, " ")) @@ -119,7 +128,8 @@ If an asset exists, it will be updated. If it doesn't exist, it will be created. cmd.Flags().StringVar(&flags.AuthToken, "auth-token", "", "Auth token for the Dash0 API (overrides active profile)") cmd.Flags().StringVar(&flags.Dataset, "dataset", "", "Dataset to operate on") cmd.Flags().StringVar(&flags.Since, "since", "", "[experimental] Delete assets removed from -f's contents since this git ref (requires --experimental/-X)") - cmd.Flags().BoolVar(&flags.Force, "force", false, "Skip the confirmation prompt for deletions triggered by --since") + cmd.Flags().BoolVar(&flags.Force, "force", false, "Skip the confirmation prompt for deletions triggered by --since; also accepts a non-ancestor --since ref") + cmd.Flags().BoolVar(&flags.AcceptNonAncestorRef, "accept-non-ancestor-ref", false, "Accept a --since ref that is not an ancestor of HEAD (e.g. after a force-push), without also skipping the per-deletion confirmation prompt") return cmd } @@ -282,7 +292,12 @@ func runApply(ctx context.Context, flags *applyFlags) error { // nothing to do with --since's ancestry check. if deletionPlan.warning != "" { fmt.Fprintf(os.Stderr, "warning: %s\n", deletionPlan.warning) - confirmed, confirmErr := confirmation.ConfirmDestructiveOperation(ctx, "Continue with --since's deletions? [y/N]: ", flags.Force) + // --force also accepts this (backward compatible: it always + // implied "proceed unattended"), but --accept-non-ancestor-ref + // lets a caller accept a doubtful ref on its own, without also + // giving up the per-asset deletion prompts below. + acceptRef := flags.Force || flags.AcceptNonAncestorRef + confirmed, confirmErr := confirmation.ConfirmDestructiveOperation(ctx, "Continue with --since's deletions? [y/N]: ", acceptRef) if confirmErr != nil || !confirmed { skipped := len(deletionPlan.plan.ByIdentifier) + len(deletionPlan.plan.AlertsByName) fmt.Fprintf(os.Stderr, "--since's deletion phase skipped; the rest of the run already completed\n") diff --git a/internal/apply/since_integration_test.go b/internal/apply/since_integration_test.go index 8ba45e4f..1c2744fc 100644 --- a/internal/apply/since_integration_test.go +++ b/internal/apply/since_integration_test.go @@ -1044,6 +1044,62 @@ func TestApply_Since_NonAncestorRef_ForceDeletesAndWarnsOnce(t *testing.T) { require.NotNil(t, deleteReq, "expected the view removed since branchA to be deleted with --force") } +// TestApply_Since_AcceptNonAncestorRefWithoutForceStillPromptsPerAsset is a +// regression test for a bug where accepting a non-ancestor --since ref +// (e.g. after a force-push) was only possible via --force, which also +// silently skipped every per-asset deletion confirmation -- coupling two +// separate decisions ("is this doubtful ref okay to proceed with" and "run +// unattended, no prompts") into a single flag. --accept-non-ancestor-ref +// answers only the first question: the ref-acceptance prompt is skipped, +// but the per-asset deletion confirmation below it still fires. +func TestApply_Since_AcceptNonAncestorRefWithoutForceStillPromptsPerAsset(t *testing.T) { + testutil.SetupTestEnv(t) + dir, branchA := setUpNonAncestorRefRepo(t) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, dashboardIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureDashboardsNotFound, + }) + server.WithDashboardsCreate(testutil.FixtureDashboardsImportSuccess) + server.OnPattern(http.MethodDelete, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: map[string]any{}, + Validator: testutil.RequireHeaders, + }) + + // Only one answer ("y") is available on the fake reader. If + // --accept-non-ancestor-ref failed to skip the ref-acceptance prompt, + // that prompt would consume it, leaving the per-asset deletion prompt + // below to hit EOF -- which ConfirmDestructiveOperation treats as an + // error ("confirmation aborted: stdin closed"), not a silent decline. + // require.NoError below would then fail, so this single answer is + // enough to prove both prompts didn't compete for it. + restore := confirmation.SetReaderForTest(strings.NewReader("y\n")) + defer restore() + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{ + "-f", filepath.Join(dir, "assets"), "--since", branchA, "--accept-non-ancestor-ref", "--experimental", + "--api-url", server.URL, "--auth-token", testAuthToken, + }) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.Contains(t, output, "Dashboard") + assert.Contains(t, output, "created") + assert.Contains(t, output, "Are you sure you want to delete View", "the per-asset deletion prompt must still fire -- --accept-non-ancestor-ref only answers the ref-acceptance question") + assert.Contains(t, output, "View") + assert.Contains(t, output, "deleted") + + deleteReq := findRequest(server.Requests(), http.MethodDelete, "/api/views/a-id") + require.NotNil(t, deleteReq, "expected the view removed since branchA to be deleted") +} + func TestApply_Since_DeclinedDeletionFailsCommand(t *testing.T) { testutil.SetupTestEnv(t) diff --git a/internal/skill/content/references/apply.md b/internal/skill/content/references/apply.md index 668d09df..97652496 100644 --- a/internal/skill/content/references/apply.md +++ b/internal/skill/content/references/apply.md @@ -8,7 +8,7 @@ Apply asset definitions from a file, directory, or stdin. If an asset already exists (matched by ID), it is updated; otherwise it is created. ```bash -dash0 apply -f [--dry-run] [--since [--force]] +dash0 apply -f [--dry-run] [--since [--force] [--accept-non-ancestor-ref]] ``` _For the exact, always-current flag list, run `dash0 --agent-mode apply --help`._ @@ -167,6 +167,18 @@ keep.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created Dashboard "Old Dashboard" (b2c3d4e5-...) deleted ``` +`--force` accepts the doubtful ref and skips every per-asset deletion confirmation together, since it always meant "run unattended" end to end. +These are two separate decisions, though: accepting a ref that might be from a force-push is not the same as wanting no further prompts at all. +`--accept-non-ancestor-ref` answers only the first one, leaving the per-asset confirmation in place: + +```bash +$ dash0 --experimental apply -f dashboards/ --since --accept-non-ancestor-ref +warning: --since '' is not an ancestor of HEAD (likely a force-push or history rewrite); deletion detection may be inaccurate +keep.yaml: Dashboard "Production Overview" (a1b2c3d4-...) created +Are you sure you want to delete Dashboard "Old Dashboard" (b2c3d4e5-...), removed since --since ref? [y/N]: y +Dashboard "Old Dashboard" (b2c3d4e5-...) deleted +``` + A document removed from git history with no `dash0.com/id` or `dash0.com/origin` at `` fails the entire `--since` run before creating, updating, or deleting anything, since there is no reliable way to know which live asset (if any) it corresponds to: ```bash From 10fbb27ab6517d534630eac44a214a2006eece13 Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 24 Aug 2026 16:26:09 +0200 Subject: [PATCH 18/42] fix(apply): stop reporting an already-deleted asset as also just deleted deleteAssetByKindAndIdentifier, deletePrometheusRuleCRD, and deleteCheckRuleByName returned a plain error, so applyDeletions had no way to tell "genuinely deleted just now" apart from "already gone, tolerated" -- it printed " deleted" unconditionally after every successful call, even when the call itself had only printed " was already deleted" moments earlier. The two lines contradicted each other and claimed a deletion that never happened, which a CI log or audit trail would take at face value. All three functions now return (alreadyDeleted bool, err error); applyDeletions skips the "deleted" print when alreadyDeleted is true, since IsAlreadyDeleted already printed its own line for that case. --- internal/apply/since.go | 73 +++++++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 25 deletions(-) diff --git a/internal/apply/since.go b/internal/apply/since.go index 7dfe858e..76f84dd9 100644 --- a/internal/apply/since.go +++ b/internal/apply/since.go @@ -279,10 +279,17 @@ func applyDeletions(ctx context.Context, apiClient dash0api.Client, dataset *str declined++ continue } - if err := deleteAssetByKindAndIdentifier(ctx, apiClient, dataset, d); err != nil { + alreadyDeleted, err := deleteAssetByKindAndIdentifier(ctx, apiClient, dataset, d) + if err != nil { return declined, fmt.Errorf("failed to delete %s %s: %w", displayKind, display, err) } - fmt.Printf("%s %s deleted\n", displayKind, display) + // alreadyDeleted means IsAlreadyDeleted already printed its own + // "was already deleted" line -- printing "deleted" here too would + // contradict it and misrepresent a no-op as a real deletion in a CI + // log or audit trail. + if !alreadyDeleted { + fmt.Printf("%s %s deleted\n", displayKind, display) + } } for _, a := range dp.plan.AlertsByName { @@ -297,10 +304,13 @@ func applyDeletions(ctx context.Context, apiClient dash0api.Client, dataset *str declined++ continue } - if err := deleteCheckRuleByName(ctx, apiClient, dataset, name); err != nil { + alreadyDeleted, err := deleteCheckRuleByName(ctx, apiClient, dataset, name) + if err != nil { return declined, fmt.Errorf("failed to delete check rule %q: %w", name, err) } - fmt.Printf("Check rule %q deleted\n", name) + if !alreadyDeleted { + fmt.Printf("Check rule %q deleted\n", name) + } } return declined, nil @@ -321,13 +331,19 @@ func applyDeletions(ctx context.Context, apiClient dash0api.Client, dataset *str // command uses: that command acts on one asset the caller named by hand, so // a 404 without --force there is more likely a typo'd id worth surfacing // loudly than a benign race. -func deleteAssetByKindAndIdentifier(ctx context.Context, apiClient dash0api.Client, dataset *string, d gitutil.Deletion) error { +// +// The returned bool reports whether the asset was already gone (rather +// than genuinely deleted by this call), so applyDeletions can avoid +// printing a "deleted" line that would contradict IsAlreadyDeleted's own +// "was already deleted" message -- printing both claims a deletion that +// never happened, which a CI log or audit trail would then take at face +// value. +func deleteAssetByKindAndIdentifier(ctx context.Context, apiClient dash0api.Client, dataset *string, d gitutil.Deletion) (alreadyDeleted bool, err error) { kind, identifier := d.Kind, d.Identifier if kind == "prometheusrule" { return deletePrometheusRuleCRD(ctx, apiClient, dataset, identifier) } - var err error switch kind { case "dashboard", "persesdashboard": err = apiClient.DeleteDashboard(ctx, identifier, dataset) @@ -350,17 +366,17 @@ func deleteAssetByKindAndIdentifier(ctx context.Context, apiClient dash0api.Clie case "team": err = apiClient.DeleteTeam(ctx, identifier) default: - return fmt.Errorf("unsupported kind for deletion: %s", kind) + return false, fmt.Errorf("unsupported kind for deletion: %s", kind) } ectx := client.ErrorContext{AssetType: asset.KindDisplayName(kind), AssetID: identifier} if err != nil { if client.IsAlreadyDeleted(err, true, ectx) { - return nil + return true, nil } - return client.HandleAPIError(err, ectx) + return false, client.HandleAPIError(err, ectx) } - return nil + return false, nil } // deletePrometheusRuleCRD deletes a whole PrometheusRule CRD by identifier. @@ -388,7 +404,12 @@ func deleteAssetByKindAndIdentifier(ctx context.Context, apiClient dash0api.Clie // A 404 on either endpoint always means "already gone" (see // deleteAssetByKindAndIdentifier's doc comment for why this is unconditional, // unlike a standalone ` delete` command). -func deletePrometheusRuleCRD(ctx context.Context, apiClient dash0api.Client, dataset *string, identifier string) error { +// +// The returned bool follows deleteAssetByKindAndIdentifier's contract: true +// only when *neither* endpoint had anything left to delete (both 404), so a +// mixed outcome -- one endpoint genuinely deleted, the other already gone -- +// is reported as a real deletion, matching the fact that something was. +func deletePrometheusRuleCRD(ctx context.Context, apiClient dash0api.Client, dataset *string, identifier string) (alreadyDeleted bool, err error) { checkRuleErr := apiClient.DeleteCheckRule(ctx, identifier, dataset) recordingRuleErr := apiClient.DeleteRecordingRule(ctx, identifier, dataset) @@ -398,16 +419,16 @@ func deletePrometheusRuleCRD(ctx context.Context, apiClient dash0api.Client, dat if checkRuleErr != nil && !checkRuleNotFound { ectx := client.ErrorContext{AssetType: "check rule", AssetID: identifier} if client.IsAlreadyDeleted(checkRuleErr, true, ectx) { - return nil + return true, nil } - return client.HandleAPIError(checkRuleErr, ectx) + return false, client.HandleAPIError(checkRuleErr, ectx) } if recordingRuleErr != nil && !recordingRuleNotFound { ectx := client.ErrorContext{AssetType: "recording rule", AssetID: identifier} if client.IsAlreadyDeleted(recordingRuleErr, true, ectx) { - return nil + return true, nil } - return client.HandleAPIError(recordingRuleErr, ectx) + return false, client.HandleAPIError(recordingRuleErr, ectx) } // "Genuinely gone" means 404 on both endpoints -- neither had anything @@ -415,11 +436,11 @@ func deletePrometheusRuleCRD(ctx context.Context, apiClient dash0api.Client, dat if checkRuleNotFound && recordingRuleNotFound { ectx := client.ErrorContext{AssetType: "PrometheusRule", AssetID: identifier} if client.IsAlreadyDeleted(checkRuleErr, true, ectx) { - return nil + return true, nil } - return client.HandleAPIError(checkRuleErr, ectx) + return false, client.HandleAPIError(checkRuleErr, ectx) } - return nil + return false, nil } // deleteCheckRuleByName resolves a check rule by its exact name (the " delete` command). -func deleteCheckRuleByName(ctx context.Context, apiClient dash0api.Client, dataset *string, name string) error { +// this is unconditional, unlike a standalone ` delete` command). The +// returned bool follows the same contract: true means nothing was actually +// deleted by this call. +func deleteCheckRuleByName(ctx context.Context, apiClient dash0api.Client, dataset *string, name string) (alreadyDeleted bool, err error) { id, err := findCheckRuleIDByName(ctx, apiClient, dataset, name) if err != nil { - return err + return false, err } if id == "" { fmt.Fprintf(os.Stderr, "Check rule %q was already deleted\n", name) - return nil + return true, nil } err = apiClient.DeleteCheckRule(ctx, id, dataset) ectx := client.ErrorContext{AssetType: "check rule", AssetID: id, AssetName: name} if err != nil { if client.IsAlreadyDeleted(err, true, ectx) { - return nil + return true, nil } - return client.HandleAPIError(err, ectx) + return false, client.HandleAPIError(err, ectx) } - return nil + return false, nil } // findCheckRuleIDByName lists every check rule in dataset and returns the ID From 202f2c7af3236b797eec64b09c4614803016b62b Mon Sep 17 00:00:00 2001 From: Michele Mancioppi Date: Mon, 24 Aug 2026 16:26:30 +0200 Subject: [PATCH 19/42] fix(asset): derive a distinct check-rule id per alert in multi-alert PrometheusRule CRDs A PrometheusRule CRD's dash0.com/id label names the CRD, not any one alert -- but ParseAsPrometheusAlertRules stamps that same label onto every alerting rule's converted check rule identically. Since a non-empty id always upserts via PUT (create-or-*replace*), a CRD with two or more alerts silently overwrote its own check rules on every apply: only the last alert in document order ended up with a real check rule server-side, while the CLI reported success for all of them. --since's per-alert deletion (AlertsByName, which resolves a removed alert to a check rule by its exact composed name) could then delete the one physical resource backing whichever alert currently matched that name -- which, in the common case, was the resource the CRD's *surviving* alert also depended on. composePrometheusRuleNames now derives a distinct id per alert when a CRD has more than one -- the CRD's own label plus a slug of the alert's composed name -- so each alert gets its own upsert target. A single-alert CRD is unaffected: its one check rule keeps the label verbatim, exactly as before. The derivation is stable across repeated applies of unchanged content and across reordering the CRD's rules, preserving the upsert idempotency a single-alert CRD already had. Root cause lives in dash0-api-client-go's ParseAsPrometheusAlertRules (a separate module); this works around it entirely within dash0-cli, since deriving the id after the fact needs no changes to that library or to --since's own name-based deletion logic, which already resolves by exact composed name rather than by id. Documents the migration implication in docs/commands.md: re-applying an existing multi-alert CRD under this fix leaves an orphaned duplicate at the CRD's literal dash0.com/id (whichever alert applied last under the old behavior), since nothing targets that literal id directly anymore -- delete it by hand once the new per-alert check rules look correct. --- .chloggen/feat_sync-action.yaml | 23 +++++ docs/commands.md | 18 +++- internal/apply/integration_test.go | 71 ++++++++++++++++ internal/asset/prometheusrule.go | 62 +++++++++++++- internal/asset/prometheusrule_test.go | 116 ++++++++++++++++++++++++++ internal/skill/content/SKILL.md | 2 +- 6 files changed, 289 insertions(+), 3 deletions(-) diff --git a/.chloggen/feat_sync-action.yaml b/.chloggen/feat_sync-action.yaml index eb634ef5..dfc07376 100644 --- a/.chloggen/feat_sync-action.yaml +++ b/.chloggen/feat_sync-action.yaml @@ -23,6 +23,29 @@ subtext: | assets' names from git history instead of only showing their id. Agent mode emits `--dry-run`'s preview as JSON. + Also, while stabilizing this feature ahead of release: + - `--since` now correctly detects an all-deletions run, whether `-f`'s target survives + empty or was removed entirely, instead of failing outright. + - Deleting a PrometheusRule CRD always cleans up both its check rule and its recording + rule, instead of trusting a single git snapshot that could undercount which endpoints + the CRD ever used. + - A CRD's recording rule is deleted when its last `record:` entry is removed, even + though the CRD's own identifier survives via a remaining `alert:` entry. + - An asset already deleted by someone else no longer fails the whole run; this no + longer requires `--force`, which keeps its own separate job of skipping confirmation + prompts. + - New `--accept-non-ancestor-ref` flag accepts a non-ancestor `--since` ref (e.g. after + a force-push) without also skipping every per-asset deletion confirmation, which + `--force` alone used to do together. + - A concurrently-deleted asset is now reported once, not as both "already deleted" and + "deleted". + - Fixed a pre-existing, `--since`-independent bug in `apply`/`check-rules create`: a + PrometheusRule CRD with 2+ alerting rules and a `dash0.com/id` label silently + collapsed to one check rule (the last alert applied overwrote the rest under the + shared id). Each alert now upserts its own derived id. Re-applying an existing + multi-alert CRD leaves an orphaned duplicate at the literal `dash0.com/id`; delete it + by hand once the new per-alert check rules look correct. + # 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. diff --git a/docs/commands.md b/docs/commands.md index 4427a18f..a857467d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -726,7 +726,7 @@ The identifier field location varies by asset kind: | `Dashboard` | `metadata.dash0Extensions.id` | | | `PersesDashboard` | `metadata.labels["dash0.com/id"]` | | | `CheckRule` | top-level `id` | | -| `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` (alerting rules) | `metadata.labels["dash0.com/id"]` | For a single-alert CRD, this label is the alert's check-rule id directly. For a CRD with two or more alerting rules, each alert gets its own derived id (the CRD's label plus a slug of the alert's own composed name) instead of sharing the CRD's label directly — see [Multi-alert PrometheusRule CRDs](#multi-alert-prometheusrule-crds) | | `PrometheusRule` (recording rules) | `metadata.labels["dash0.com/id"]` | | | `SyntheticCheck` | `metadata.labels["dash0.com/id"]` | | | `View` | `metadata.labels["dash0.com/id"]` | | @@ -740,6 +740,22 @@ Notification channels and spam filters are the two exceptions: their server APIs When `list -o yaml` or `get -o yaml` exports an existing asset, the server-assigned ID is rendered into the correct field, so the export-edit-reapply workflow round-trips through the identifier automatically. +#### Multi-alert PrometheusRule CRDs + +A `PrometheusRule` CRD's `dash0.com/id` label is the CRD's own identifier, not one specific alert's. +For a single alerting rule, that label unambiguously names its one check rule, so it's used verbatim. +For two or more alerting rules, each one instead gets its own identifier: the CRD's label, plus `--`, plus a slug of that alert's own composed name (` - `, lowercased, with every run of non-alphanumeric characters collapsed to a single hyphen). +For example, a CRD labeled `dash0.com/id: app-rules` with alerts `HighErrorRate` and `DiskFull` in a group named `test-group` upserts check rules `app-rules--test-group-higherrorrate` and `app-rules--test-group-diskfull`. + +This derivation exists because upsert is PUT (create-or-*replace*), and the CRD-level label is the only identifier a multi-alert CRD has to work with — reusing it verbatim for every alert would upsert them all to the exact same check rule, so only the last alert applied in document order would end up with a real check rule at all, silently losing every other one. +The derived id is stable across repeated applies of unchanged content (neither the CRD's label nor an unrenamed alert's composed name changes) and across reordering the CRD's rules (it depends on the alert's name, not its position), so upsert idempotency holds the same way it does for a single-alert CRD. +Renaming an alert changes its derived id, the same way `--since` already treats a renamed alert as a delete-and-recreate (see [`apply --since`](#apply---since-experimental)'s PrometheusRule CRD handling) — there is no separate, more stable identity for one alert within a CRD to fall back to. + +> [!NOTE] +> If a multi-alert CRD with a `dash0.com/id` label was applied before this derivation existed, its literal `dash0.com/id` held whichever alert last applied under the old, colliding behavior. +> Re-applying that CRD now creates a fresh check rule per alert at each alert's own derived id, and leaves the old check rule at the literal `dash0.com/id` untouched — it becomes an orphaned duplicate. +> Delete it by hand (`dash0 check-rules delete `) once the new per-alert check rules look correct. + ### PrometheusRule annotation merge A PrometheusRule document's top-level `metadata.annotations` are merged into each alerting rule's own annotations, key by key. A rule that sets the same key wins for that key only, and still inherits the rest. diff --git a/internal/apply/integration_test.go b/internal/apply/integration_test.go index aa477dfc..98700341 100644 --- a/internal/apply/integration_test.go +++ b/internal/apply/integration_test.go @@ -663,6 +663,77 @@ spec: assert.Equal(t, "test-group - HighErrorRate", rule.Name) } +// TestApply_PrometheusRule_MultiAlertWithSharedID_CreatesDistinctCheckRules +// is a regression test for a bug where every alert in a multi-alert +// PrometheusRule CRD got the CRD's own shared dash0.com/id as its +// check-rule id, so each alert's upsert (PUT, create-or-*replace*) silently +// overwrote whatever the previous alert in the same apply run had just +// written -- only the last alert in document order ended up with a real +// check rule server-side, even though apply reported success for both. +// Each alert must now PUT to its own distinct, derived id. +func TestApply_PrometheusRule_MultiAlertWithSharedID_CreatesDistinctCheckRules(t *testing.T) { + testutil.SetupTestEnv(t) + + tmpDir := t.TempDir() + yamlFile := filepath.Join(tmpDir, "prometheusrule.yaml") + err := os.WriteFile(yamlFile, []byte(`apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: test-rules + labels: + dash0.com/id: shared-id +spec: + groups: + - name: test-group + interval: 1m + rules: + - alert: HighErrorRate + expr: sum(rate(errors[5m])) > 0.1 + - alert: DiskFull + expr: disk > 0.9 +`), 0644) + require.NoError(t, err) + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, checkRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureCheckRulesNotFound, + }) + server.WithCheckRulesUpdate(testutil.FixtureCheckRulesImportSuccess) + + cmd := NewApplyCmd() + cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.Contains(t, output, "Check rule") + + highErrorRateReq := findRequest(server.Requests(), http.MethodPut, "/api/alerting/check-rules/shared-id--test-group-higherrorrate") + diskFullReq := findRequest(server.Requests(), http.MethodPut, "/api/alerting/check-rules/shared-id--test-group-diskfull") + require.NotNil(t, highErrorRateReq, "expected a PUT to the HighErrorRate alert's own derived id") + require.NotNil(t, diskFullReq, "expected a PUT to the DiskFull alert's own derived id, not a second write to the same id as HighErrorRate") + + var highErrorRateRule, diskFullRule dash0api.PrometheusAlertRule + require.NoError(t, json.Unmarshal(highErrorRateReq.Body, &highErrorRateRule)) + require.NoError(t, json.Unmarshal(diskFullReq.Body, &diskFullRule)) + assert.Equal(t, "test-group - HighErrorRate", highErrorRateRule.Name) + assert.Equal(t, "test-group - DiskFull", diskFullRule.Name) + + // Never PUT to the CRD's own literal shared id (exact match, not a + // prefix -- both derived ids above start with "shared-id--" and would + // otherwise match a prefix check): that would still be the old + // collapsing behavior. + for _, req := range server.Requests() { + if req.Method == http.MethodPut { + assert.NotEqual(t, "/api/alerting/check-rules/shared-id", req.Path, "must never PUT directly to the CRD's shared id") + } + } +} + func TestApply_PersesDashboard_Created(t *testing.T) { testutil.SetupTestEnv(t) diff --git a/internal/asset/prometheusrule.go b/internal/asset/prometheusrule.go index 4fcc52a3..26b6878a 100644 --- a/internal/asset/prometheusrule.go +++ b/internal/asset/prometheusrule.go @@ -67,6 +67,30 @@ func PrometheusRuleHasRecordingRule(data []byte) (bool, error) { // document order, then rules in document order, skipping recording rules (those // without an `alert`). That alignment lets the names zip onto the returned // rules by index. +// +// For a CRD with more than one alerting rule, this also rewrites each rule's +// Id: the SDK conversion (ParseAsPrometheusAlertRules) stamps the CRD's own +// shared dash0.com/id onto every alert identically, since that's the only id +// a CRD carries. A single-alert CRD is fine with that -- the shared id +// unambiguously names its one check rule -- but for 2+ alerts it means every +// alert upserts (PUT, create-or-*replace*) to the exact same id, so each +// apply silently overwrites whatever the previous alert in the same run just +// wrote: only the last alert in document order ends up with a real check +// rule server-side, even though the CLI reports success for all of them. +// Deriving a distinct id per alert -- the shared id plus a slug of the +// alert's own composed name -- gives each one its own upsert target. The +// derivation is stable across repeated applies of the same content (the +// dash0.com/id label doesn't change, and an alert's composed name doesn't +// change unless the alert itself is renamed) and across reordering the +// CRD's rules (it depends on the name, not position), so upsert idempotency +// holds the same way it already does for a single-alert CRD. +// +// Migration note: re-applying an existing multi-alert CRD under this fix +// creates a fresh check rule per alert at each alert's derived id; the CRD's +// literal shared dash0.com/id, which used to hold whichever alert applied +// last under the old behavior, is not touched by the new per-alert ids and +// becomes an orphaned duplicate -- delete it by hand once the new per-alert +// check rules look correct. func composePrometheusRuleNames(data []byte, rules []*dash0api.PrometheusAlertRule) error { kind, err := dash0yaml.DetectKind(data) if err != nil { @@ -80,15 +104,51 @@ func composePrometheusRuleNames(data []byte, rules []*dash0api.PrometheusAlertRu if err != nil { return err } + multiAlert := len(names) > 1 for i, name := range names { if i >= len(rules) { return nil } - rules[i].Name = name.CheckRuleName() + composedName := name.CheckRuleName() + rules[i].Name = composedName + if multiAlert && rules[i].Id != nil && *rules[i].Id != "" { + derived := deriveAlertCheckRuleID(*rules[i].Id, composedName) + rules[i].Id = &derived + } } return nil } +// deriveAlertCheckRuleID derives a per-alert check-rule identifier for a +// PrometheusRule CRD with more than one alerting rule, from the CRD's own +// shared id and the alert's composed name. See composePrometheusRuleNames' +// doc comment for the full rationale. +func deriveAlertCheckRuleID(sharedID, composedName string) string { + return sharedID + "--" + slugify(composedName) +} + +// slugify lowercases s and replaces every run of characters that aren't +// lowercase letters or digits with a single hyphen, trimming any leading or +// trailing hyphen. Used to fold a human-readable composed check-rule name +// (e.g. "rule-group - DiskFull") into a predictable, URL-safe identifier +// fragment (e.g. "rule-group-diskfull"). +func slugify(s string) string { + var b strings.Builder + lastWasHyphen := true // avoid a leading hyphen + for _, r := range strings.ToLower(s) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + lastWasHyphen = false + continue + } + if !lastWasHyphen { + b.WriteByte('-') + lastWasHyphen = true + } + } + return strings.TrimSuffix(b.String(), "-") +} + // ExtractPrometheusAlertNames parses a PrometheusRule CRD document and // returns the (group name, alert name) pair for every alerting rule, in // document order. Recording rules are skipped. diff --git a/internal/asset/prometheusrule_test.go b/internal/asset/prometheusrule_test.go index f40c9c97..590780ba 100644 --- a/internal/asset/prometheusrule_test.go +++ b/internal/asset/prometheusrule_test.go @@ -59,6 +59,122 @@ spec: assert.Equal(t, "group-b - DiskFull", rules[1].Name) } +// TestParseCheckRules_SingleAlertKeepsSharedID pins that a single-alert CRD's +// one check rule keeps the CRD's own dash0.com/id verbatim -- there is only +// ever one alert to upsert, so the shared id unambiguously names it, and +// existing single-alert users' check rules must keep resolving to the same +// id they've always had. +func TestParseCheckRules_SingleAlertKeepsSharedID(t *testing.T) { + crd := []byte(`apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: single + labels: + dash0.com/id: shared-id +spec: + groups: + - name: g + rules: + - alert: HighErrorRate + expr: errors > 0 +`) + + rules, err := ParseCheckRules(crd) + require.NoError(t, err) + require.Len(t, rules, 1) + require.NotNil(t, rules[0].Id) + assert.Equal(t, "shared-id", *rules[0].Id) +} + +// TestParseCheckRules_MultiAlertDerivesDistinctIDs is a regression test for +// a bug where every alert in a multi-alert PrometheusRule CRD got the exact +// same check-rule id (the CRD's own shared dash0.com/id), so each alert's +// upsert (PUT, create-or-*replace*) silently overwrote whatever the +// previous alert in the same apply run had just written: only the last +// alert in document order ended up with a real check rule server-side, +// even though the CLI reported success for both. Each alert must now get +// its own distinct, non-empty id derived from the shared id and its own +// composed name. +func TestParseCheckRules_MultiAlertDerivesDistinctIDs(t *testing.T) { + crd := []byte(`apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: multi + labels: + dash0.com/id: shared-id +spec: + groups: + - name: test-group + rules: + - alert: HighErrorRate + expr: errors > 0 + - alert: DiskFull + expr: disk > 0 +`) + + rules, err := ParseCheckRules(crd) + require.NoError(t, err) + require.Len(t, rules, 2) + + require.NotNil(t, rules[0].Id) + require.NotNil(t, rules[1].Id) + assert.NotEqual(t, *rules[0].Id, *rules[1].Id, "each alert must get its own id, not the CRD's shared id repeated") + assert.NotEqual(t, "shared-id", *rules[0].Id, "the derived id must not collide with the CRD's own literal shared id either") + assert.NotEqual(t, "shared-id", *rules[1].Id) + assert.Equal(t, "shared-id--test-group-higherrorrate", *rules[0].Id) + assert.Equal(t, "shared-id--test-group-diskfull", *rules[1].Id) +} + +// TestParseCheckRules_MultiAlertDerivedIDsAreStableAcrossReapply pins the +// idempotency property the derivation depends on: re-parsing the identical +// CRD content must produce the identical derived ids, so repeated applies +// keep upserting the same check rules rather than creating new ones each +// time. +func TestParseCheckRules_MultiAlertDerivedIDsAreStableAcrossReapply(t *testing.T) { + crd := []byte(`apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: multi + labels: + dash0.com/id: shared-id +spec: + groups: + - name: test-group + rules: + - alert: HighErrorRate + expr: errors > 0 + - alert: DiskFull + expr: disk > 0 +`) + + first, err := ParseCheckRules(crd) + require.NoError(t, err) + second, err := ParseCheckRules(crd) + require.NoError(t, err) + + require.Len(t, first, 2) + require.Len(t, second, 2) + assert.Equal(t, *first[0].Id, *second[0].Id) + assert.Equal(t, *first[1].Id, *second[1].Id) +} + +func TestSlugify(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"test-group - DiskFull", "test-group-diskfull"}, + {"g - HighErrorRate", "g-higherrorrate"}, + {"Group A - Alert/With Slashes", "group-a-alert-with-slashes"}, + {" leading and trailing ", "leading-and-trailing"}, + {"UPPER_CASE", "upper-case"}, + {"", ""}, + } + for _, c := range cases { + assert.Equal(t, c.want, slugify(c.in), "slugify(%q)", c.in) + } +} + // TestParseCheckRules_BooleanLiteralAlertNamePreserved is a regression test // for a bug where an alert name that is a YAML boolean literal (Y, N, yes, // no, on, off, true, false, and case variants), written unquoted, was diff --git a/internal/skill/content/SKILL.md b/internal/skill/content/SKILL.md index ef8bc1c7..2372fe9b 100644 --- a/internal/skill/content/SKILL.md +++ b/internal/skill/content/SKILL.md @@ -52,7 +52,7 @@ Every asset type accepts a user-defined identifier in its YAML/JSON document. Wh | `Dashboard` | `metadata.dash0Extensions.id` | | `PersesDashboard` | `metadata.labels["dash0.com/id"]` | | `CheckRule` | top-level `id` | -| `PrometheusRule` (alerting or recording) | `metadata.labels["dash0.com/id"]` | +| `PrometheusRule` (alerting or recording) | `metadata.labels["dash0.com/id"]` (a CRD with 2+ alerting rules derives a distinct id per alert instead: `