diff --git a/.chloggen/feat_sync-action.yaml b/.chloggen/feat_sync-action.yaml new file mode 100644 index 00000000..dfc07376 --- /dev/null +++ b/.chloggen/feat_sync-action.yaml @@ -0,0 +1,55 @@ +# 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: [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. +# 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. + + 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. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Default: '[user]' +change_logs: [] diff --git a/.chloggen/feat_sync-action_dryrun_output.yaml b/.chloggen/feat_sync-action_dryrun_output.yaml new file mode 100644 index 00000000..8630b04e --- /dev/null +++ b/.chloggen/feat_sync-action_dryrun_output.yaml @@ -0,0 +1,47 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: breaking + +# 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: "`apply --dry-run` changed its output shape, including without `--since`" + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +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. +# Use pipe (|) for multiline entries. +subtext: | + `--since` renders its deletions in the same listing as creates and updates, so that + listing had to name each row's operation and order the two kinds of row together. Plain + `--dry-run` shares the renderer and therefore changed too, in three ways: + + - Each row is now prefixed with `*` and its operation instead of a per-file ordinal: + `1. Dashboard "Production Overview" (a1b2c3d4-...)` becomes + `* Apply Dashboard "Production Overview" (a1b2c3d4-...)`. + - Rows within a file are ordered by identifier rather than by their position in the + file, so a document's row can move relative to its siblings. + - In agent mode, `--dry-run` now emits the documented JSON array of + `{path, changes: [{op, kind, name, originOrId}]}` instead of the human-readable text, + matching every other command's agent-mode contract. + + Separately, `Dash0Team` now renders as `Team` wherever an asset kind is displayed, so + `apply` reports `Team "Backend Team" created` instead of `Dash0Team "Backend Team" + created`. Every other kind already had a human-readable display name; teams fell + through to the raw kind identifier, which also contradicted the `--- Team (before)` + header their own update diff printed. + + Scripts that parsed `--dry-run`'s text output, or grepped for `Dash0Team`, need + updating. Agent-mode callers should read the JSON rather than parsing text. + +# If your change doesn't affect end users or the exported elements of any package, +# you should instead start your pull request title with "chore" or use the "Skip Changelog" label. +# Optional: The change log or logs in which this entry should be included. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Default: '[user]' +change_logs: [] diff --git a/.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/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 98ee9d06..66f94ac0 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`. @@ -726,11 +726,11 @@ 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"]` | | -| `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 | @@ -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. @@ -871,19 +887,22 @@ 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] [--accept-non-ancestor-ref]] ``` | 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`; 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. 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. @@ -925,12 +944,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 +967,190 @@ 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, kind, name, originOrId, since}]}`, `op` being `"apply"` or `"delete"`, `kind` the asset's display kind (e.g. `"View"`, `"Dashboard"`), and `since` the `--since` ref that determined the change (present only for `op: "delete"`, since an `"apply"` row comes from `-f`'s current contents, not from any ref). + +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 +``` + +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 +$ 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. + +A ref written as `~N` or `^N` (including the bare `~`/`^` shorthand for `N=1`) whose base resolves fine, but whose own history has fewer than `N+1` commits — `--since HEAD~1` against a fresh, single-commit repository, for example, the first thing many people try when setting up a test or demo repository for `--since` — names that specifically, rather than suggesting a typo or a too-shallow clone: + +```bash +$ dash0 --experimental apply -f dashboards/ --since HEAD~1 --dry-run +Error: --since 'HEAD~1' could not be resolved: "HEAD" has only 1 commit of history, not enough for "HEAD~1" to resolve 1 commit further back +Hint: pass a ref that exists this far back in the repository's history, or skip --since for this invocation +``` + +Any other unresolvable ref (a genuine typo, a too-shallow clone) still surfaces the generic suggestion instead, since git's own error text for those cases doesn't distinguish the reason on its own: + +```bash +$ dash0 --experimental apply -f dashboards/ --since bogus-ref --dry-run +Error: --since 'bogus-ref' could not be resolved +Hint: check the ref for a typo; if this is a shallow clone (actions/checkout defaults to fetch-depth: 1), re-run the checkout with fetch-depth: 0 so the ref's history is available +``` + +`-f`'s target not being inside a git repository at all gets a direct, single-sentence error rather than git's own nested plumbing failure: + +```bash +$ dash0 --experimental apply -f /tmp/assets/ --since HEAD~1 --dry-run +Error: --since 'HEAD~1' requires /tmp/assets/ to be inside a git repository, but it is not (no .git found there or in any parent directory) +Hint: point -f at a path inside the repository that tracks these assets, or drop --since to apply without deletion detection +``` + +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 +``` + +`--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 that carried no identifier 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 identifier its kind is upserted by, so deletion cannot be determined reliably: + removed.yaml +Hint: without a stable identifier there is no way to tell which live asset each document was; delete these assets directly in Dash0, or skip --since for this invocation +``` + +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 +``` + +Removing the whole CRD (the file or document disappears entirely, rather than one alert within it) deletes every alerting rule the CRD had, plus its recording rule if it had one — for a [multi-alert CRD](#multi-alert-prometheusrule-crds), that means one delete call per alert's own derived id, not one call against the CRD's literal `dash0.com/id` label, since that label alone was never each alert's real check-rule id to begin with: + +```bash +$ dash0 --experimental apply -f rules/ --since HEAD~1 --force +Check rule "service-alerts - HighLatency" deleted +Check rule "service-alerts - HighErrorRate" 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: + +```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. + +##### 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 +3189,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/docs/testing.md b/docs/testing.md index 1710126e..6374a0e5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -60,3 +60,60 @@ 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. +- `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 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/go.mod b/go.mod index b144de69..b5f23b4e 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..9fa5acdc 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,41 @@ 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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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/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 +56,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 +78,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 +101,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 +158,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 +171,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 +268,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 +307,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 +318,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 +335,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/apply/apply.go b/internal/apply/apply.go index 89546171..6bc43ced 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -3,6 +3,7 @@ package apply import ( "bytes" "context" + "errors" "fmt" "io" "os" @@ -15,6 +16,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 +30,23 @@ 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 + // 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 @@ -54,7 +74,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 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 @@ -71,7 +93,16 @@ 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 + + # 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, " ")) @@ -79,6 +110,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)\nHint: --since needs a file or directory path to compare against git history; pass -f instead", flags.Since) + } cmd.SilenceUsage = true return runApply(cmd.Context(), &flags) }, @@ -89,6 +127,9 @@ 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; 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 } @@ -136,6 +177,14 @@ type applyResult struct { func runApply(ctx context.Context, flags *applyFlags) error { var documents []assetDocument var fromDirectory bool + // targetVanished records that fromDirectory was only a guess (always + // true) because the target no longer exists on disk at all, so os.Stat + // couldn't say whether it used to be a file or a directory. Once + // computeDeletionPlan runs, it can answer that from git history at the + // --since ref, and the guess is corrected below -- otherwise a vanished + // single-file target would render like a multi-file directory scan + // (grouped by its git-recorded path instead of the literal -f argument). + var targetVanished bool var err error if flags.File == "-" { @@ -146,16 +195,33 @@ 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 + targetVanished = 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()) @@ -163,7 +229,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") } @@ -175,8 +241,20 @@ 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 targetVanished { + fromDirectory = plan.targetWasDirectoryAtRef + } + } + if flags.DryRun { - return printDryRun(documents, fromDirectory) + return runDryRun(documents, fromDirectory, flags.File, flags.Since, deletionPlan) } // Create API client @@ -217,6 +295,36 @@ 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) + // --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") + 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 } @@ -261,37 +369,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 { @@ -484,6 +561,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)) @@ -547,48 +634,37 @@ func readMultiDocumentYAML(filePath string, stdin io.Reader) ([]assetDocument, e 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. 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 { - 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 @@ -618,12 +694,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 { @@ -815,6 +886,13 @@ func validatePrometheusRule(data []byte) error { if !asset.PrometheusRuleHasAlerts(crd) && asset.RecordingOnlyPrometheusRule(crd) == nil { return fmt.Errorf("PrometheusRule contains no alerting or recording rules") } + names, err := asset.ExtractPrometheusAlertNames(data) + if err != nil { + return err + } + if err := asset.CheckAlertNameCollisions(names); err != nil { + return err + } return nil } diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go index 3d1ad0a1..62471027 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)) @@ -374,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/dryrun.go b/internal/apply/dryrun.go new file mode 100644 index 00000000..0bdf9e49 --- /dev/null +++ b/internal/apply/dryrun.go @@ -0,0 +1,248 @@ +package apply + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "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 -- dryRunChangeJSON 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 kind and display name, and its +// id/origin. +type dryRunChangeJSON struct { + Op string `json:"op"` + Kind string `json:"kind"` + Name string `json:"name"` + OriginOrID string `json:"originOrId"` + // Since names the --since ref that determined this change, present only + // for op "delete" (an "apply" row's presence is determined by -f's + // current contents, not by any ref). An agent deciding whether a + // deletion is safe to approve needs the same two facts a human reading + // the text-mode output already sees at a glance: what kind of asset + // this is, and that a deletion this consequential was derived from + // comparing against a specific git ref, not inferred from -f alone. + Since string `json:"since,omitempty"` +} + +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 := asset.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(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{ + 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 +} + +// flattenSorted collapses per-file rows into one list sorted by originOrID, +// for the single-file -f target both renderers report flat: there is no real +// per-file grouping to show, so the file boundaries buildDryRunRows sorted +// within are dropped and the rows re-sorted as a whole. +func flattenSorted(rowsByFile map[string][]dryRunRow, files []string) []dryRunRow { + 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 }) + return flat +} + +// 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, since) + } + 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 { + for _, r := range flattenSorted(rowsByFile, files) { + 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)) +} + +// 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. +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. since is stamped onto every "delete" change (never +// "apply", which isn't determined by any ref) so an agent deciding whether +// to approve a deletion doesn't have to already be tracking which --since +// invocation it came from. +func renderDryRunJSON(rowsByFile map[string][]dryRunRow, files []string, fromDirectory bool, fileArg, since string) error { + toChanges := func(rows []dryRunRow) []dryRunChangeJSON { + changes := make([]dryRunChangeJSON, 0, len(rows)) + for _, r := range rows { + change := dryRunChangeJSON{Op: r.op, Kind: asset.KindDisplayName(r.kind), Name: r.name, OriginOrID: r.originOrID} + if r.op == "delete" { + change.Since = since + } + changes = append(changes, change) + } + return changes + } + + out := []dryRunFileJSON{} + if !fromDirectory { + out = append(out, dryRunFileJSON{Path: fileArg, Changes: toChanges(flattenSorted(rowsByFile, files))}) + } 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..723a7ce2 --- /dev/null +++ b/internal/apply/dryrun_test.go @@ -0,0 +1,330 @@ +package apply + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/dash0hq/dash0-cli/internal/agentmode" + "github.com/dash0hq/dash0-cli/internal/asset" + 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" +) + +// 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 + 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", Kind: "Dashboard", 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", Kind: "View", Name: "error-logs-view", OriginOrID: "33333333-3333-3333-3333-333333333333"}, out[0].Changes[0]) + assert.Equal(t, dryRunChangeJSON{Op: "delete", Kind: "Check rule", Name: "High Error Rate", OriginOrID: "44444444-4444-4444-4444-444444444444", Since: "abc123"}, out[0].Changes[1]) +} + +// TestRunDryRun_JSON_DeletionIncludesKindAndSinceRef is a regression test +// for a bug where agent mode's --dry-run JSON gave an approving agent less +// context than a human reading the text output: the text renderer's +// "Delete View "Team Logs" (team-logs)" names the asset's kind, but the +// JSON change entry carried only {op, name, originOrId} -- no kind, and no +// mention of which --since ref determined the deletion. An agent deciding +// whether a deletion is safe to approve needs to know what kind of asset is +// about to be removed (a view is a very different risk than a production +// dashboard) as much as a human does. +func TestRunDryRun_JSON_DeletionIncludesKindAndSinceRef(t *testing.T) { + withAgentMode(t, true) + + dp := &deletionPlan{ + plan: gitutil.DeletionPlan{ + ByIdentifier: []gitutil.Deletion{ + {Kind: "view", Identifier: "team-logs", Path: "dashboards.yaml"}, + }, + }, + names: map[string]string{"dashboards.yaml": "Team Logs"}, + } + + stdout := testutil.CaptureStdout(t, func() { + require.NoError(t, runDryRun(nil, true, "dashboards", "HEAD~1", dp)) + }) + + var out []dryRunFileJSON + require.NoError(t, json.Unmarshal([]byte(stdout), &out)) + require.Len(t, out, 1) + require.Len(t, out[0].Changes, 1) + assert.Equal(t, dryRunChangeJSON{Op: "delete", Kind: "View", Name: "Team Logs", OriginOrID: "team-logs", Since: "HEAD~1"}, out[0].Changes[0]) +} + +// 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. +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") +} + +// prometheusRuleCRDForDryRun is a two-alert CRD whose dash0.com/id label is +// what buildDryRunRows keys crdFileByIdentifier on, so an alert deletion +// (which carries only its surviving CRD's identifier, never a file path) +// lands under the same file entry as the CRD's own "apply" row. +const prometheusRuleCRDForDryRun = `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 +` + +// TestRunDryRun_TextMode_PrometheusRuleAlertPartialDeletion is the --dry-run +// analogue of TestApply_Since_PrometheusRuleAlertPartialDeletion: one alert +// removed from a CRD that otherwise survives. The surviving CRD's "apply" +// row and the removed alert's "delete" row must group under the same file, +// and the delete line must carry the detail suffix naming which CRD the +// alert was removed from -- without it, "Delete Check rule "test-group - +// DiskFull"" gives no hint that the CRD itself is being kept. +func TestRunDryRun_TextMode_PrometheusRuleAlertPartialDeletion(t *testing.T) { + withAgentMode(t, false) + + documents := []assetDocument{ + {kind: "PrometheusRule", name: "test-rules", filePath: "rules.yaml", raw: []byte(prometheusRuleCRDForDryRun)}, + } + dp := &deletionPlan{ + plan: gitutil.DeletionPlan{ + AlertsByName: []gitutil.AlertDeletion{ + { + CRDIdentifier: "shared-id", + PrometheusAlertName: asset.PrometheusAlertName{GroupName: "test-group", AlertName: "DiskFull"}, + }, + }, + }, + } + + stdout := testutil.CaptureStdout(t, func() { + require.NoError(t, runDryRun(documents, true, "dir", "HEAD~1", dp)) + }) + + assert.Contains(t, stdout, "1 deletion pending due to --since 'HEAD~1'") + // A single file heading, with both rows nested under it. + assert.Equal(t, 1, strings.Count(stdout, "\n rules.yaml\n"), "both rows must group under the surviving CRD's own file") + assert.Contains(t, stdout, ` * Apply PrometheusRule "test-rules" (shared-id)`) + assert.Contains(t, stdout, ` * Delete Check rule "test-group - DiskFull" (alert removed from PrometheusRule shared-id)`) +} + +// TestRunDryRun_JSON_PrometheusRuleAlertPartialDeletion pins the agent-mode +// JSON for the same case. The detail suffix is text-only (dryRunChangeJSON +// has no field for it), so the JSON's originOrId is the surviving CRD's +// identifier -- an agent must not read that as "the CRD is being deleted", +// which is why the change's name is the alert's composed check-rule name and +// its kind is CheckRule, not PrometheusRule. +func TestRunDryRun_JSON_PrometheusRuleAlertPartialDeletion(t *testing.T) { + withAgentMode(t, true) + + documents := []assetDocument{ + {kind: "PrometheusRule", name: "test-rules", filePath: "rules.yaml", raw: []byte(prometheusRuleCRDForDryRun)}, + } + dp := &deletionPlan{ + plan: gitutil.DeletionPlan{ + AlertsByName: []gitutil.AlertDeletion{ + { + CRDIdentifier: "shared-id", + PrometheusAlertName: asset.PrometheusAlertName{GroupName: "test-group", AlertName: "DiskFull"}, + }, + }, + }, + } + + stdout := testutil.CaptureStdout(t, func() { + require.NoError(t, runDryRun(documents, true, "dir", "HEAD~1", dp)) + }) + + var out []dryRunFileJSON + require.NoError(t, json.Unmarshal([]byte(stdout), &out)) + require.Len(t, out, 1, "the alert deletion must join its surviving CRD's file entry") + assert.Equal(t, "rules.yaml", out[0].Path) + require.Len(t, out[0].Changes, 2) + assert.Equal(t, dryRunChangeJSON{Op: "apply", Kind: "PrometheusRule", Name: "test-rules", OriginOrID: "shared-id"}, out[0].Changes[0]) + assert.Equal(t, dryRunChangeJSON{Op: "delete", Kind: "Check rule", Name: "test-group - DiskFull", OriginOrID: "shared-id", Since: "HEAD~1"}, out[0].Changes[1]) +} diff --git a/internal/apply/integration_test.go b/internal/apply/integration_test.go index aa477dfc..efc4249d 100644 --- a/internal/apply/integration_test.go +++ b/internal/apply/integration_test.go @@ -663,6 +663,120 @@ 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") + } + } +} + +// TestApply_PrometheusRule_MultiAlertCollidingDerivedIDsFailValidation is a +// regression test for a bug where two alerts in the same multi-alert CRD +// whose composed names collide once slugified (e.g. "High CPU" and +// "High_CPU" both fold to "high-cpu") would derive the identical +// check-rule id, silently reopening the exact overwrite bug id derivation +// exists to prevent. This must fail validation before any API call, not +// silently apply and clobber one alert with the other. +func TestApply_PrometheusRule_MultiAlertCollidingDerivedIDsFailValidation(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: g + rules: + - alert: High CPU + expr: cpu > 0.9 + - alert: High_CPU + expr: cpu > 0.8 +`), 0644) + require.NoError(t, err) + + // No mock server routes registered at all: validation must fail before + // any API call is attempted. + server := testutil.NewMockServer(t, testutil.FixturesDir()) + + cmd := NewApplyCmd() + cmd.SetArgs([]string{"-f", yamlFile, "--api-url", server.URL, "--auth-token", testAuthToken}) + + cmdErr := cmd.Execute() + require.Error(t, cmdErr) + assert.Contains(t, cmdErr.Error(), "g - High CPU") + assert.Contains(t, cmdErr.Error(), "g - High_CPU") + assert.Empty(t, server.Requests(), "validation must fail before any API call") +} + func TestApply_PersesDashboard_Created(t *testing.T) { testutil.SetupTestEnv(t) diff --git a/internal/apply/since.go b/internal/apply/since.go new file mode 100644 index 00000000..46dcd2d4 --- /dev/null +++ b/internal/apply/since.go @@ -0,0 +1,671 @@ +package apply + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "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 + // 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 + // 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 + // declaredCheckRuleIDs is every check-rule id -f's current contents still + // declare, so a deletion dispatched by identifier never removes an asset + // this same run created or updated. See declaredCheckRuleIDs. + declaredCheckRuleIDs map[string]bool + // targetWasDirectoryAtRef reports whether -f's target was a directory + // (rather than a single file) the last time it existed, per git history + // at --since's ref. runApply uses this to correct its own fromDirectory + // guess for a target that no longer exists on disk at all: os.Stat can't + // tell file from directory once the path is gone, but the ref usually + // still can. + targetWasDirectoryAtRef bool +} + +// 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) + } + + // 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. + 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) + + repoDir := resolvedAncestor + if missingSuffix == "" { + // absFile exists: preserve the original file-vs-directory dance + // exactly as before. resolvedAncestor equals absFile itself here + // (nearestExistingAncestor found no missing suffix because the + // target already exists), so a single-file target must still be + // rehomed to its *parent* directory -- `git -C ` + // fails outright ("Not a directory"), unlike `git -C `. + info, err := os.Stat(absFile) + if err != nil { + return nil, fmt.Errorf("failed to stat %s: %w", flags.File, err) + } + if !info.IsDir() { + repoDir = filepath.Dir(absFile) + } + } + repo := gitutil.Repo{Dir: repoDir} + + repoRoot, err := repo.Root(ctx) + if err != nil { + if gitutil.IsNotAGitRepository(err) { + return nil, fmt.Errorf("--since '%s' requires %s to be inside a git repository, but it is not (no .git found there or in any parent directory)\nHint: point -f at a path inside the repository that tracks these assets, or drop --since to apply without deletion detection", flags.Since, flags.File) + } + 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\nHint: a CI-provided ref variable is likely unset (e.g. github.event.before on a workflow_dispatch or schedule trigger); skip --since for this invocation, or pass an explicit ref", 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\nHint: some CI systems report this value for a branch's first push; skip --since for this invocation, or pass an explicit ref", flags.Since, gitutil.AllZerosSHA) + case gitutil.RefUnresolvable: + // Best-effort: a simple "~N"/"^N" ref whose base + // resolves fine but doesn't have N commits of history behind it + // (e.g. --since HEAD~1 against a single-commit repo) gets a + // specific reason instead of the generic typo-or-shallow-clone + // guess, neither of which applies to that case. + if reason := repo.ExplainUnresolvableRef(ctx, flags.Since); reason != "" { + return nil, fmt.Errorf("--since '%s' could not be resolved: %s\nHint: pass a ref that exists this far back in the repository's history, or skip --since for this invocation", flags.Since, reason) + } + return nil, fmt.Errorf("--since '%s' could not be resolved\nHint: check the ref for a typo; if this is a shallow clone (actions/checkout defaults to fetch-depth: 1), re-run the checkout with fetch-depth: 0 so the ref's history is available", 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 = "" + } + + // A sparse checkout leaves tracked files off disk, so the disk side of the + // diff sees fewer than the ref side and every absent one looks deleted. + // Refuse rather than delete assets git still declares. + sparse, err := repo.HasSkipWorktreeFiles(ctx, scope) + if err != nil { + return nil, err + } + if sparse { + return nil, fmt.Errorf("--since '%s' cannot run against a sparse checkout: files tracked under %s are absent from disk, so they would be detected as deletions\nHint: run from a full checkout (git sparse-checkout disable), or drop --since to apply without deletion detection", flags.Since, flags.File) + } + + targetWasDirectoryAtRef, err := repo.IsTreeAtRef(ctx, sha, scope) + if err != nil { + // The target may not have existed at this ref at all (e.g. it was + // created after ref, then deleted before now) -- that's a legitimate + // "nothing to report either way" outcome, not a hard failure; fall + // back to treating it as not-a-directory, matching absFile's own + // current-disk-state IsDir() default when nothing else is known. + targetWasDirectoryAtRef = false + } + + 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 identifier its kind is upserted by, so deletion cannot be determined reliably:\n %s\nHint: without a stable identifier there is no way to tell which live asset each document was; delete these assets directly in Dash0, or skip --since for this invocation", + flags.Since, pluralize(len(plan.NoIdentifier), "document"), strings.Join(plan.NoIdentifier, "\n ")) + } + + names := resolveDeletionNames(before, plan.ByIdentifier) + + return &deletionPlan{plan: plan, warning: warning, names: names, scope: scope, declaredCheckRuleIDs: declaredCheckRuleIDs(after), targetWasDirectoryAtRef: targetWasDirectoryAtRef}, 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.Stat(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 +// from before's content — the "before" (--since ref) Snapshot already read +// while building it (gitutil.Snapshot.RawContent), never a second read of +// the same git blob. before is the exact Snapshot that produced every +// deletion candidate in deletions (via gitutil.Diff), so a lookup miss can +// only mean a document that didn't parse cleanly, never a blob that +// genuinely needs (re-)fetching. Documents are parsed once per file and +// cached in memory for the duration of this call, so a multi-document file +// with several deletion candidates only pays the parse cost once. +// +// This is display polish, not correctness-critical: a lookup miss (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(before gitutil.Snapshot, 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 { + if raw, ok := before.RawContent[basePath]; ok { + docs, _ = parseMultiDocumentYAML(raw) + } + docsByFile[basePath] = docs + } + if docIndex < len(docs) && docs[docIndex].name != "" { + names[d.Path] = docs[docIndex].name + } + } + 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 +// 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) + // 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 %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) + } + 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 + } + if !confirmed { + fmt.Fprintf(os.Stderr, "%s %s: deletion declined\n", displayKind, display) + declined++ + continue + } + alreadyDeleted, err := deleteAssetByKindAndIdentifier(ctx, apiClient, dataset, d, dp.declaredCheckRuleIDs) + if err != nil { + return declined, fmt.Errorf("failed to delete %s %s: %w", displayKind, display, err) + } + // 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) + } + } + + var nameIndex checkRuleNameIndex + if len(dp.plan.AlertsByName) > 0 { + // One listing for the whole loop, not one per alert. + var err error + nameIndex, err = buildCheckRuleNameIndex(ctx, apiClient, dataset) + if err != nil { + return declined, err + } + } + 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 + } + alreadyDeleted, err := deleteCheckRuleByName(ctx, apiClient, dataset, name, nameIndex) + if err != nil { + return declined, fmt.Errorf("failed to delete check rule %q: %w", name, err) + } + if !alreadyDeleted { + 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. +// +// 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. +// +// 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, declared map[string]bool) (alreadyDeleted bool, err error) { + kind, identifier := d.Kind, d.Identifier + if kind == "prometheusrule" { + return deletePrometheusRuleCRD(ctx, apiClient, dataset, identifier, d.PrometheusAlerts, declared) + } + + switch kind { + case "dashboard", "persesdashboard": + err = apiClient.DeleteDashboard(ctx, identifier, dataset) + case "checkrule": + if declared[identifier] { + // A surviving single-alert CRD's check rule lives here. Reporting + // "nothing deleted" keeps applyDeletions from printing "deleted". + warnSkippedDeclaredCheckRule(identifier, fmt.Sprintf("check rule %q", identifier)) + return true, nil + } + 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": + err = apiClient.DeleteSpamFilter(ctx, identifier, dataset) + case "notificationchannel": + err = apiClient.DeleteNotificationChannel(ctx, identifier) + case "team": + err = apiClient.DeleteTeam(ctx, identifier) + default: + 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 true, nil + } + return false, client.HandleAPIError(err, ectx) + } + return false, 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. +// +// alerts is the CRD's alerting-rule list at the "before" (--since ref) +// snapshot (gitutil.Deletion.PrometheusAlerts). A CRD with two or more +// alerts has each alert's real check rule living at its own derived id +// (asset.DeriveAlertCheckRuleID), never at the CRD's literal identifier -- +// see composePrometheusRuleNames' doc comment for why -- so deleting such a +// CRD must attempt each alert's derived id individually. A CRD with zero or +// one alert keeps using the literal identifier directly, matching how +// applyPrometheusRule creates it. +// +// Every endpoint attempted (every check-rule id, plus the recording-rule +// endpoint) tolerates a 404: 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 all of them +// unconditionally is safe by the same logic every other kind already +// relies on. The one tradeoff: a check rule or recording rule that happens +// to share one of these identifiers by coincidence (not because it came +// from this CRD) would be deleted too — accepted as an edge case narrow +// enough not to justify leaving real orphaned assets behind. +// +// A 404 on any endpoint always means "already gone" (see +// deleteAssetByKindAndIdentifier's doc comment for why this is +// unconditional, unlike a standalone ` delete` command). +// +// The returned bool follows deleteAssetByKindAndIdentifier's contract: true +// only when *nothing* attempted had anything left to delete (every check +// rule id and the recording rule all 404), so a mixed outcome -- anything +// genuinely deleted, the rest 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, alerts []asset.PrometheusAlertName, declared map[string]bool) (alreadyDeleted bool, err error) { + checkRuleIDs := asset.CheckRuleIDsOccupiedByCRD(identifier, alerts) + if len(alerts) > 1 { + // A multi-alert CRD applied before per-alert derivation existed left + // its one check rule at the literal id (docs/commands.md's multi-alert + // migration note); the per-id 404 tolerance below makes this extra + // attempt free when there is no such orphan. + checkRuleIDs = append([]string{identifier}, checkRuleIDs...) + } + + var lastCheckRuleNotFoundErr error + checkRulesAllNotFound := true + for _, id := range checkRuleIDs { + if declared[id] { + // Not attempted, so not a "found nothing" either. + warnSkippedDeclaredCheckRule(id, fmt.Sprintf("PrometheusRule %q", identifier)) + continue + } + checkRuleErr := apiClient.DeleteCheckRule(ctx, id, dataset) + if checkRuleErr == nil { + checkRulesAllNotFound = false + continue + } + if !dash0api.IsNotFound(checkRuleErr) { + return false, client.HandleAPIError(checkRuleErr, client.ErrorContext{AssetType: "check rule", AssetID: id}) + } + lastCheckRuleNotFoundErr = checkRuleErr + } + + recordingRuleErr := apiClient.DeleteRecordingRule(ctx, identifier, dataset) + recordingRuleNotFound := recordingRuleErr != nil && dash0api.IsNotFound(recordingRuleErr) + if recordingRuleErr != nil && !recordingRuleNotFound { + return false, client.HandleAPIError(recordingRuleErr, client.ErrorContext{AssetType: "recording rule", AssetID: identifier}) + } + + // "Genuinely gone" means 404 on every check-rule id attempted and on + // the recording-rule endpoint -- nothing had anything for this CRD. + if checkRulesAllNotFound && recordingRuleNotFound { + notFoundErr := lastCheckRuleNotFoundErr + if notFoundErr == nil { + notFoundErr = recordingRuleErr + } + ectx := client.ErrorContext{AssetType: "PrometheusRule", AssetID: identifier} + if client.IsAlreadyDeleted(notFoundErr, true, ectx) { + return true, nil + } + return false, client.HandleAPIError(notFoundErr, ectx) + } + return false, nil +} + +// declaredCheckRuleIDs collects every check-rule id -f's current contents +// still declare. Dispatch resolves an identifier to an endpoint, dropping the +// kind half of the (kind, identifier) key Snapshot.Identifiers uses elsewhere: +// a removed PrometheusRule and a surviving CheckRule sharing an identifier are +// two plan entries but one DELETE /api/alerting/check-rules/, which would +// undo phase 1's own create/update. The collision runs both ways. +// +// Tolerating an orphan here is deliberate: leaving an asset behind is +// recoverable by hand, deleting one the user just declared is not. +func declaredCheckRuleIDs(after gitutil.Snapshot) map[string]bool { + declared := map[string]bool{} + for key := range after.Identifiers { + switch key.Kind { + case "checkrule": + declared[key.Identifier] = true + case "prometheusrule": + // Occupied ids only: a multi-alert CRD's literal identifier holds + // an orphan, not something it declares, so it stays reclaimable. + for _, id := range asset.CheckRuleIDsOccupiedByCRD(key.Identifier, after.PrometheusAlertsByIdentifier[key.Identifier]) { + declared[id] = true + } + } + } + return declared +} + +// warnSkippedDeclaredCheckRule reports a skipped check-rule deletion, so the +// orphan it leaves behind is not invisible. +func warnSkippedDeclaredCheckRule(id, removedDisplay string) { + fmt.Fprintf(os.Stderr, "warning: not deleting check rule %q on behalf of removed %s: the current contents of -f still declare a check rule with that identifier, and deleting it would undo this run's own create/update; remove it by hand if it is a leftover\n", id, removedDisplay) +} + +// deleteCheckRuleByName deletes the check rule index resolves name to. Name +// 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. +// +// 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). 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, index checkRuleNameIndex) (alreadyDeleted bool, err error) { + id, err := index.resolve(name) + if err != nil { + return false, err + } + if id == "" { + fmt.Fprintf(os.Stderr, "Check rule %q was already deleted\n", name) + 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 true, nil + } + return false, client.HandleAPIError(err, ectx) + } + return false, nil +} + +// checkRuleNameIndex maps each live check rule's name to every check rule +// carrying it, built once per run so resolving N removed alerts costs one +// listing rather than N, and so a shared name is visible as the ambiguity it +// is rather than resolving to whichever the API listed first. +type checkRuleNameIndex map[string][]checkRuleMatch + +type checkRuleMatch struct { + id string + source string // dash0.com/origin-derived system of record, "" when unset +} + +// foreignCheckRuleSources are the systems of record --since must not delete +// on behalf of an alert removed from -f: a same-named check rule owned by one +// of them merely collides. A denylist, not an allowlist: the CLI strips +// dash0.com/origin before sending, so its own check rules read back as "api" +// or unset, and CrdSource's contract treats an unknown value as "api" too. +var foreignCheckRuleSources = map[string]bool{ + string(dash0api.Ui): true, + string(dash0api.Terraform): true, + string(dash0api.Operator): true, + string(dash0api.Platform): true, +} + +func buildCheckRuleNameIndex(ctx context.Context, apiClient dash0api.Client, dataset *string) (checkRuleNameIndex, error) { + index := checkRuleNameIndex{} + iter := apiClient.ListCheckRulesIter(ctx, dataset) + for iter.Next() { + item := iter.Current() + if item.Name == nil { + continue + } + match := checkRuleMatch{id: item.Id} + if item.Source != nil { + match.source = string(*item.Source) + } + index[*item.Name] = append(index[*item.Name], match) + } + if err := iter.Err(); err != nil { + return nil, fmt.Errorf("failed to list check rules: %w", err) + } + return index, nil +} + +// resolve returns the id of the one deletable check rule named name, "" when +// there is none, or an error when two carry it -- deleting the wrong one is +// unrecoverable, and the CRD's identifier cannot disambiguate them. +func (index checkRuleNameIndex) resolve(name string) (string, error) { + var deletable, foreign []string + for _, match := range index[name] { + if foreignCheckRuleSources[match.source] { + foreign = append(foreign, match.source) + continue + } + deletable = append(deletable, match.id) + } + + if len(deletable) > 1 { + sort.Strings(deletable) + return "", fmt.Errorf("check rule %q is ambiguous: %d check rules carry that name (%s)\nHint: --since resolves an alert removed from a surviving PrometheusRule CRD by name, since the CRD's shared identifier cannot distinguish its alerts; rename or delete the duplicates in Dash0, or skip --since for this invocation", name, len(deletable), strings.Join(deletable, ", ")) + } + if len(deletable) == 0 { + if len(foreign) > 0 { + sort.Strings(foreign) + fmt.Fprintf(os.Stderr, "warning: not deleting check rule %q: every check rule with that name is managed by another system (%s), not by this repository\n", name, strings.Join(foreign, ", ")) + } + return "", nil + } + return deletable[0], nil +} diff --git a/internal/apply/since_integration_test.go b/internal/apply/since_integration_test.go new file mode 100644 index 00000000..116b0f97 --- /dev/null +++ b/internal/apply/since_integration_test.go @@ -0,0 +1,1892 @@ +//go:build integration + +package apply + +import ( + "encoding/json" + "fmt" + "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_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 +// 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 +// 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") + + // The survivor moves back to the literal id, abandoning its derived one; + // apply's write at the literal id would otherwise leave a duplicate. + require.NotNil(t, findRequest(server.Requests(), http.MethodDelete, "/api/alerting/check-rules/shared-id--test-group-higherrorrate"), + "expected a DELETE for the surviving alert's now-abandoned derived id") + require.NotNil(t, findRequest(server.Requests(), http.MethodPut, apiPathCheckRules+"/shared-id"), + "the surviving alert must be written at the CRD's literal id, which declaredCheckRuleIDs must not then delete") + // Exact path: findRequest matches by prefix, which the derived id shares. + for _, req := range server.Requests() { + if req.Method == http.MethodDelete && req.Path == apiPathCheckRules+"/shared-id" { + t.Error("the literal id is the surviving alert's new home and must never be deleted") + } + } +} + +// 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_RecordDroppedBeforeDeletion_StillDeletesRecordingRule +// is a regression test for a bug where deleting a whole PrometheusRule CRD +// 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 id = "app-rules-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: app-rules + labels: + 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 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 rules.yaml entirely") + + 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, + }) + // 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{}, + 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, 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/"+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") +} + +// TestApply_Since_PrometheusRuleWholeCRDDeletion_MultiAlertDeletesEachDerivedID +// is a regression test for a bug where deleting a whole multi-alert +// PrometheusRule CRD only ever attempted DELETE against the CRD's literal +// dash0.com/id -- but composePrometheusRuleNames gives each of a +// multi-alert CRD's check rules its own derived id (asset. +// DeriveAlertCheckRuleID), never the literal shared id, once there are two +// or more alerts. The literal-id delete attempt 404s, is silently +// tolerated as "already deleted", and every real check rule the CRD ever +// created is left orphaned forever -- exactly the kind of silent data loss +// the derived-id fix was written to prevent, just moved from create-time +// collisions to delete-time blind spots. +func TestApply_Since_PrometheusRuleWholeCRDDeletion_MultiAlertDeletesEachDerivedID(t *testing.T) { + testutil.SetupTestEnv(t) + + const id = "app-rules" + + 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: app-rules + labels: + dash0.com/id: `+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 mixed multi-alert 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 rules.yaml entirely") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, viewIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureViewsNotFound, + }) + server.WithViewsUpdate(testutil.FixtureViewsImportSuccess) + // The CRD's literal shared id was never a real check rule once + // derivation kicked in for its 2 alerts: registered to 404, so this + // test fails loudly if the buggy code path stops there and reports + // success without ever reaching the real, derived-id resources. + 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, "deleted") + + highErrorRateID := "app-rules--test-group-higherrorrate" + diskFullID := "app-rules--test-group-diskfull" + require.NotNil(t, findRequest(server.Requests(), http.MethodDelete, "/api/alerting/check-rules/"+highErrorRateID), "expected a DELETE for the HighErrorRate alert's own derived check-rule id") + require.NotNil(t, findRequest(server.Requests(), http.MethodDelete, "/api/alerting/check-rules/"+diskFullID), "expected a DELETE for the DiskFull alert's own derived check-rule id") + + // The literal shared id is attempted alongside the derived ids, never + // instead of them: for a CRD applied before per-alert derivation existed, + // that is where its one check rule sits. + require.NotNil(t, findRequest(server.Requests(), http.MethodDelete, "/api/alerting/check-rules/"+id), + "expected a DELETE for the CRD's literal shared id too, to reclaim a pre-derivation orphan") +} + +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") +} + +// 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" + + 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, 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: + 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) + 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, "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") +} + +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") +} + +// 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) + + 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, 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_DryRunPreview_SingleFileTargetVanishedRendersFlat is a +// regression test for a bug where a single-*file* -f target that no longer +// exists on disk at all (as opposed to a directory) still rendered as if +// it were a multi-file directory scan: fromDirectory was forced true purely +// because os.Stat couldn't tell file from directory once the path was +// gone, so agent-mode JSON grouped the deletion under its git-recorded path +// instead of the literal -f argument, and text mode added a spurious "from +// N files" clause. Once computeDeletionPlan determines from git history +// that the vanished target was a file, not a directory, rendering must +// fall back to the flat, single-target shape. +func TestApply_Since_DryRunPreview_SingleFileTargetVanishedRendersFlat(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") + + target := filepath.Join(dir, "dashboard.yaml") + 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(target)) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove dashboard") + + t.Run("text mode", func(t *testing.T) { + cmd := newSinceTestCmd() + cmd.SetArgs([]string{"-f", target, "--since", before, "--dry-run", "--experimental"}) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + assert.NotContains(t, output, "from 1 file", "a single-file target has no per-document file count worth stating") + assert.Contains(t, output, "Delete Dashboard") + }) + + t.Run("agent mode JSON", func(t *testing.T) { + withAgentMode(t, true) + + cmd := newSinceTestCmd() + cmd.SetArgs([]string{"-f", target, "--since", before, "--dry-run", "--experimental"}) + + var cmdErr error + output := testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + + require.NoError(t, cmdErr) + var out []dryRunFileJSON + require.NoError(t, json.Unmarshal([]byte(output), &out)) + require.Len(t, out, 1) + assert.Equal(t, target, out[0].Path, "a vanished single-file target must be keyed by the literal -f argument, not its git-recorded path") + }) +} + +// 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 +// 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 \"Drop noisy health checks\" (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") +} + +// TestApply_Since_WholeCRDDeletionSpares_DeclaredCheckRule is a regression +// test for silent over-deletion: a whole-CRD deletion dispatches to the +// check-rule endpoint by identifier alone, dropping the (kind, identifier) +// keying used everywhere else, so a `kind: CheckRule` document still +// declaring that id was deleted right after phase 1 created it. +func TestApply_Since_WholeCRDDeletionSpares_DeclaredCheckRule(t *testing.T) { + testutil.SetupTestEnv(t) + + const sharedID = "collision-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: test-rules + labels: + dash0.com/id: `+sharedID+` +spec: + groups: + - name: test-group + rules: + - alert: HighErrorRate + expr: sum(rate(errors[5m])) > 0.1 +`) + // A plain CheckRule that happens to carry the same identifier string. + writeFileFixture(t, dir, "checkrule.yaml", `apiVersion: dash0.com/v1alpha1 +kind: CheckRule +id: `+sharedID+` +name: Standalone Rule +expression: up == 0 +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add CRD and colliding check rule") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + // Remove only the CRD. checkrule.yaml survives and is applied in phase 1. + require.NoError(t, os.Remove(filepath.Join(dir, "rules.yaml"))) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove the CRD") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, checkRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureCheckRulesNotFound, + }) + server.WithCheckRulesUpdate(testutil.FixtureCheckRulesImportSuccess) + 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, + Body: map[string]any{}, + }) + + 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) + + require.NotNil(t, findRequest(server.Requests(), http.MethodPut, apiPathCheckRules+"/"+sharedID), + "the surviving CheckRule document must still be applied in phase 1") + assert.Nil(t, findRequest(server.Requests(), http.MethodDelete, apiPathCheckRules+"/"+sharedID), + "the CRD's deletion must not delete a check rule the current contents still declare") +} + +// TestApply_Since_CheckRuleDeletionSpares_DeclaredCRDsCheckRule covers the +// other direction: a removed `kind: CheckRule` whose id is where a surviving +// single-alert CRD's check rule lives, which phase 1 just upserted. +func TestApply_Since_CheckRuleDeletionSpares_DeclaredCRDsCheckRule(t *testing.T) { + testutil.SetupTestEnv(t) + + const sharedID = "collision-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") + + crd := `apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: test-rules + labels: + dash0.com/id: ` + sharedID + ` +spec: + groups: + - name: test-group + rules: + - alert: HighErrorRate + expr: sum(rate(errors[5m])) > 0.1 +` + writeFileFixture(t, dir, "rules.yaml", crd) + writeFileFixture(t, dir, "checkrule.yaml", `apiVersion: dash0.com/v1alpha1 +kind: CheckRule +id: `+sharedID+` +name: Standalone Rule +expression: up == 0 +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add CRD and colliding check rule") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + // The surviving CRD's check rule lives at the very id this would target. + require.NoError(t, os.Remove(filepath.Join(dir, "checkrule.yaml"))) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove the standalone check rule") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, checkRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureCheckRulesNotFound, + }) + server.WithCheckRulesUpdate(testutil.FixtureCheckRulesImportSuccess) + 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 + testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + require.NoError(t, cmdErr) + + require.NotNil(t, findRequest(server.Requests(), http.MethodPut, apiPathCheckRules+"/"+sharedID), + "the surviving CRD's alert must still be applied in phase 1") + assert.Nil(t, findRequest(server.Requests(), http.MethodDelete, apiPathCheckRules+"/"+sharedID), + "the removed CheckRule must not delete the surviving CRD's check rule at the same id") +} + +// TestApply_Since_MultipleAlertDeletionsListCheckRulesOnce pins that the +// alert-deletion path lists check rules once per run rather than once per +// removed alert. +func TestApply_Since_MultipleAlertDeletionsListCheckRulesOnce(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: Survivor + expr: up == 1 + - alert: DiskFull + expr: disk > 0.9 + - alert: MemFull + expr: mem > 0.9 +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add rules with three alerts") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + + // Drop two of the three alerts; the CRD (and its 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: Survivor + expr: up == 1 +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove two alerts") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, checkRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureCheckRulesNotFound, + }) + server.WithCheckRulesUpdate(testutil.FixtureCheckRulesImportSuccess) + server.On(http.MethodGet, apiPathCheckRules, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: []map[string]any{ + {"dataset": "default", "id": "disk-full-id", "name": "test-group - DiskFull"}, + {"dataset": "default", "id": "mem-full-id", "name": "test-group - MemFull"}, + }, + 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 + testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + require.NoError(t, cmdErr) + + require.NotNil(t, findRequest(server.Requests(), http.MethodDelete, apiPathCheckRules+"/disk-full-id")) + require.NotNil(t, findRequest(server.Requests(), http.MethodDelete, apiPathCheckRules+"/mem-full-id")) + + listings := 0 + for _, req := range server.Requests() { + if req.Method == http.MethodGet && req.Path == apiPathCheckRules { + listings++ + } + } + assert.Equal(t, 1, listings, "two removed alerts must share one check-rule listing") +} + +// TestApply_Since_AlertDeletionSkipsForeignOwnedCheckRule pins that a +// same-named check rule owned by another system of record is left alone, and +// that the API's source field actually reaches the index. +func TestApply_Since_AlertDeletionSkipsForeignOwnedCheckRule(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: Survivor + expr: up == 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")) + + 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: Survivor + expr: up == 1 +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "remove DiskFull") + + server := testutil.NewMockServer(t, testutil.FixturesDir()) + server.OnPattern(http.MethodGet, checkRuleIDPattern, testutil.MockResponse{ + StatusCode: http.StatusNotFound, + BodyFile: testutil.FixtureCheckRulesNotFound, + }) + server.WithCheckRulesUpdate(testutil.FixtureCheckRulesImportSuccess) + // The only check rule with that name is Terraform's. + server.On(http.MethodGet, apiPathCheckRules, testutil.MockResponse{ + StatusCode: http.StatusOK, + Body: []map[string]any{ + {"dataset": "default", "id": "terraform-owned-id", "name": "test-group - DiskFull", "source": "terraform"}, + }, + 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 + testutil.CaptureStdout(t, func() { + cmdErr = cmd.Execute() + }) + require.NoError(t, cmdErr) + + assert.Nil(t, findRequest(server.Requests(), http.MethodDelete, apiPathCheckRules+"/terraform-owned-id"), + "a Terraform-managed check rule must never be deleted on behalf of an alert removed from -f") +} diff --git a/internal/apply/since_test.go b/internal/apply/since_test.go new file mode 100644 index 00000000..0a4e59a5 --- /dev/null +++ b/internal/apply/since_test.go @@ -0,0 +1,816 @@ +package apply + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/dash0hq/dash0-cli/internal/asset" + "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) + 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]) +} + +// TestComputeDeletionPlan_SingleFileTargetStillExists is a regression test +// for a bug where -f pointing directly at a single file (rather than a +// directory) that still exists on disk made every --since invocation fail +// outright: computeDeletionPlan derived repoDir from the file's own path +// without ever taking its parent directory, so the subsequent `git -C +// rev-parse --show-toplevel` failed with "fatal: cannot change to +// '': Not a directory" -- contradicting docs/commands.md's own claim +// that "-f's target must be inside a git repository (a single file or a +// directory both work)". +func TestComputeDeletionPlan_SingleFileTargetStillExists(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") + + target := filepath.Join(dir, "dashboard.yaml") + 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")) + + flags := &applyFlags{File: target, Since: before} + dp, err := computeDeletionPlan(context.Background(), flags) + require.NoError(t, err, "a single-file -f target that still exists must not fail to locate its repository") + assert.True(t, dp.plan.IsEmpty(), "the file is unchanged since before, so there is nothing to delete") +} + +// 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_DanglingSymlinkTargetIsTreatedAsMissing is a +// regression test for a bug where a --since target that is a dangling +// symlink (the symlink itself exists, but whatever it points at doesn't) +// was treated inconsistently across the three places that ask "does -f's +// target exist": runApply's os.Stat(flags.File) and BuildSnapshotFromDisk's +// os.Stat(scope) both follow symlinks, so both see it as "gone" and route +// to the all-deletions-tolerant path -- but nearestExistingAncestor used +// os.Lstat, which sees the dangling symlink itself as "existing" and stops +// there, so computeDeletionPlan then failed outright trying to resolve it +// as a real path instead of taking the same tolerant path the other two +// checks would. +func TestComputeDeletionPlan_DanglingSymlinkTargetIsTreatedAsMissing(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)) + // A dangling symlink at the target's own path -- the symlink itself + // exists, but its destination never did. + require.NoError(t, os.Symlink(filepath.Join(dir, "never-existed"), target)) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "replace dashboards with a dangling symlink") + + flags := &applyFlags{File: target, Since: before} + dp, err := computeDeletionPlan(context.Background(), flags) + require.NoError(t, err, "a dangling symlink target must be treated the same as a missing one, not fail to resolve") + require.Len(t, dp.plan.ByIdentifier, 1) + assert.Equal(t, "a1b2c3d4-5678-90ab-cdef-1234567890ab", dp.plan.ByIdentifier[0].Identifier) +} + +// 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. +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) + } +} + +// TestCheckRuleIDsOccupiedByCRD_SingleAlertUsesLiteralIdentifier pins that a +// CRD with zero or one alerting rule keeps using the CRD's own literal +// identifier, unchanged: composePrometheusRuleNames never derives a distinct +// id for it either, so it lives at that literal id. +func TestCheckRuleIDsOccupiedByCRD_SingleAlertUsesLiteralIdentifier(t *testing.T) { + assert.Equal(t, []string{"shared-id"}, asset.CheckRuleIDsOccupiedByCRD("shared-id", nil)) + assert.Equal(t, []string{"shared-id"}, asset.CheckRuleIDsOccupiedByCRD("shared-id", []asset.PrometheusAlertName{ + {GroupName: "g", AlertName: "A"}, + })) +} + +// TestCheckRuleIDsOccupiedByCRD_MultiAlertUsesDerivedIDs is a regression +// test for a bug where a whole-CRD deletion for a multi-alert CRD only ever +// targeted the CRD's literal identifier, which -- once +// composePrometheusRuleNames derives a distinct id per alert for 2+ alerts +// -- was never where any of the real check rules actually lived. Each +// alert's own derived id must be targeted instead. +func TestCheckRuleIDsOccupiedByCRD_MultiAlertUsesDerivedIDs(t *testing.T) { + ids := asset.CheckRuleIDsOccupiedByCRD("shared-id", []asset.PrometheusAlertName{ + {GroupName: "g", AlertName: "A"}, + {GroupName: "g", AlertName: "B"}, + }) + assert.Equal(t, []string{ + asset.DeriveAlertCheckRuleID("shared-id", "g - A"), + asset.DeriveAlertCheckRuleID("shared-id", "g - B"), + }, ids) + assert.NotContains(t, ids, "shared-id", "a multi-alert CRD's alerts never live at the literal shared id") +} + +// 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} + before, err := gitutil.BuildSnapshotFromRef(context.Background(), repo, "HEAD", "") + require.NoError(t, err) + + deletions := []gitutil.Deletion{ + {Kind: "view", Identifier: "view-id", Path: "assets.yaml"}, + {Kind: "checkrule", Identifier: "rule-id", Path: "assets.yaml#1"}, + } + names := resolveDeletionNames(before, deletions) + assert.Equal(t, "first-view", names["assets.yaml"]) + assert.Equal(t, "Second Document Rule", names["assets.yaml#1"]) +} + +// TestResolveDeletionNames_ReusesBeforeSnapshotContent is a regression test +// pinning that resolveDeletionNames never reads git again: it must resolve +// every name from before.RawContent, which BuildSnapshotFromRef already +// populated while building the "before" snapshot. Deleting the git repo +// entirely (but keeping before, already built beforehand) proves this -- +// a version of resolveDeletionNames that shelled out to git a second time +// would find nothing to read and return an empty map, not the real names. +func TestResolveDeletionNames_ReusesBeforeSnapshotContent(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, "dashboard.yaml", `kind: Dashboard +metadata: + name: my-dashboard + dash0Extensions: + id: gone-id +spec: + display: + name: My Dashboard +`) + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "seed") + + repo := gitutil.Repo{Dir: dir} + before, err := gitutil.BuildSnapshotFromRef(context.Background(), repo, "HEAD", "") + require.NoError(t, err) + + // The repository (and thus any chance of a second git read succeeding) + // is gone by the time resolveDeletionNames runs. + require.NoError(t, os.RemoveAll(dir)) + + deletions := []gitutil.Deletion{ + {Kind: "dashboard", Identifier: "gone-id", Path: "dashboard.yaml"}, + } + names := resolveDeletionNames(before, deletions) + assert.Equal(t, "My Dashboard", names["dashboard.yaml"]) +} + +// TestResolveDeletionNames_MissingRawContentIsNonFatal is a regression test +// pinning that a deletion candidate whose path was never captured in +// before.RawContent (e.g. a since-rewritten blob a differently-scoped +// snapshot never read) 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_MissingRawContentIsNonFatal(t *testing.T) { + before := gitutil.Snapshot{RawContent: map[string][]byte{}} + deletions := []gitutil.Deletion{ + {Kind: "dashboard", Identifier: "gone-id", Path: "never-captured.yaml"}, + } + names := resolveDeletionNames(before, deletions) + assert.Empty(t, names) +} + +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") + assert.Contains(t, err.Error(), "\nHint: check the ref for a typo", "a genuine typo must still get the generic suggestions, as an agent-mode-parseable hint") +} + +// TestComputeDeletionPlan_NotAGitRepository is a regression test for a bug +// where running --since against a directory that was never a git +// repository at all produced a deeply nested, hard-to-read error: "--since +// '' requires to be inside a git repository: failed to +// determine repository root for : git rev-parse --show-toplevel: exit +// status 128 (stderr: fatal: not a git repository (or any of the parent +// directories): .git)". The common case now gets one clean sentence +// instead of three layers of wrapped git plumbing errors. +func TestComputeDeletionPlan_NotAGitRepository(t *testing.T) { + dir := t.TempDir() // deliberately never `git init`-ed + flags := &applyFlags{File: dir, Since: "HEAD~1"} + _, err := computeDeletionPlan(context.Background(), flags) + require.Error(t, err) + assert.Contains(t, err.Error(), "to be inside a git repository, but it is not") + assert.NotContains(t, err.Error(), "failed to determine repository root", "the nested git-plumbing wrapping must not leak into the message") + assert.NotContains(t, err.Error(), "exit status", "the raw git exec error must not leak into the message") +} + +// TestComputeDeletionPlan_InsufficientHistory is a regression test for a +// bug where --since HEAD~1 against a fresh, single-commit repository -- +// the first thing many people try when setting up a --since test or demo +// repo -- was told to check for a typo or a too-shallow clone, neither of +// which applies: there is simply no earlier commit yet. +func TestComputeDeletionPlan_InsufficientHistory(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, "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", "only commit") + + flags := &applyFlags{File: dir, Since: "HEAD~1"} + _, err := computeDeletionPlan(context.Background(), flags) + require.Error(t, err) + assert.Contains(t, err.Error(), "1 commit") + assert.NotContains(t, err.Error(), "check the ref for a typo", "the generic suggestions don't apply here and must not be shown") +} + +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 identifier its kind is upserted by") + 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") +} + +// TestCheckRuleNameIndex_Resolve covers resolve's three outcomes. Ambiguity +// matters most: the previous first-exact-match lookup silently deleted +// whichever check rule the API happened to list first. +func TestCheckRuleNameIndex_Resolve(t *testing.T) { + const name = "group - Alert" + + t.Run("single deletable match resolves", func(t *testing.T) { + index := checkRuleNameIndex{name: {{id: "id-1"}}} + id, err := index.resolve(name) + require.NoError(t, err) + assert.Equal(t, "id-1", id) + }) + + t.Run("no match resolves to empty, not an error", func(t *testing.T) { + id, err := checkRuleNameIndex{}.resolve(name) + require.NoError(t, err) + assert.Empty(t, id, "an absent check rule is already in the desired end state") + }) + + t.Run("two deletable matches error instead of guessing", func(t *testing.T) { + index := checkRuleNameIndex{name: {{id: "id-2"}, {id: "id-1"}}} + _, err := index.resolve(name) + require.Error(t, err) + assert.Contains(t, err.Error(), "ambiguous") + assert.Contains(t, err.Error(), "id-1, id-2", "both candidates must be named, sorted") + assert.Contains(t, err.Error(), "\nHint:") + }) + + t.Run("a foreign-owned match is not deletable", func(t *testing.T) { + for _, source := range []string{"ui", "terraform", "operator", "platform"} { + index := checkRuleNameIndex{name: {{id: "id-1", source: source}}} + id, err := index.resolve(name) + require.NoError(t, err) + assert.Empty(t, id, "a check rule managed by %s is a different asset that merely collides", source) + } + }) + + t.Run("a foreign match does not make its deletable sibling ambiguous", func(t *testing.T) { + index := checkRuleNameIndex{name: {{id: "id-terraform", source: "terraform"}, {id: "id-1"}}} + id, err := index.resolve(name) + require.NoError(t, err) + assert.Equal(t, "id-1", id) + }) + + t.Run("api, dash0-cli and an unrecognized source stay deletable", func(t *testing.T) { + // CrdSource's contract is to treat an unknown value as "api", and a + // CLI-applied check rule carries no origin of its own at all. + for _, source := range []string{"", "api", "dash0-cli", "something-new"} { + index := checkRuleNameIndex{name: {{id: "id-1", source: source}}} + id, err := index.resolve(name) + require.NoError(t, err) + assert.Equal(t, "id-1", id, "source %q must not be treated as foreign", source) + } + }) +} + +// TestComputeDeletionPlan_SparseCheckoutRefuses is a regression test for +// silent data loss: the ref side of the diff comes from `git ls-tree`, which +// enumerates the whole commit, while the disk side walks only what is +// materialized. Under a sparse checkout every tracked-but-absent asset became +// a deletion candidate, and --force deleted assets git still declares. +func TestComputeDeletionPlan_SparseCheckoutRefuses(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, "keep.yaml", "kind: View\nmetadata:\n name: keep\n labels:\n dash0.com/id: keep-id\nspec:\n query: \"true\"\n") + writeFileFixture(t, dir, "hidden.yaml", "kind: View\nmetadata:\n name: hidden\n labels:\n dash0.com/id: hidden-id\nspec:\n query: \"true\"\n") + runGitCmd(t, dir, "add", "-A") + runGitCmd(t, dir, "commit", "-q", "-m", "add both") + before := strings.TrimSpace(runGitCmd(t, dir, "rev-parse", "HEAD")) + runGitCmd(t, dir, "update-index", "--skip-worktree", "hidden.yaml") + require.NoError(t, os.Remove(filepath.Join(dir, "hidden.yaml"))) + + _, err := computeDeletionPlan(context.Background(), &applyFlags{File: dir, Since: before}) + require.Error(t, err, "hidden.yaml is still declared in git and must never become a deletion candidate") + assert.Contains(t, err.Error(), "sparse checkout") + assert.Contains(t, err.Error(), "\nHint:") +} 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/asset/identifier.go b/internal/asset/identifier.go new file mode 100644 index 00000000..eef9c288 --- /dev/null +++ b/internal/asset/identifier.go @@ -0,0 +1,86 @@ +package asset + +import ( + "fmt" + "strings" + + sigsyaml "sigs.k8s.io/yaml" +) + +// PrometheusAlertName identifies a single alerting rule inside a PrometheusRule +// CRD by the group it lives in and its own alert name. +// +// Dash0 has no per-alert server-side id — a CRD's alerting rules share the +// CRD's own identifier — so this pair is the only stable handle on one alert +// within a CRD. --since uses it to resolve a removed alert by its composed +// check-rule name. +type PrometheusAlertName struct { + GroupName string + AlertName string +} + +// CheckRuleName composes the check-rule name Dash0 gives an alerting rule +// converted from a PrometheusRule CRD: " - ", matching +// the Dash0 Kubernetes operator and the Terraform provider. +func (p PrometheusAlertName) CheckRuleName() string { + return fmt.Sprintf("%s - %s", p.GroupName, p.AlertName) +} + +type identifierProbe struct { + Kind string `json:"kind"` + ID string `json:"id"` + Metadata struct { + Labels map[string]string `json:"labels"` + Dash0Extensions struct { + ID string `json:"id"` + } `json:"dash0Extensions"` + } `json:"metadata"` +} + +// ExtractIdentifier returns the user-defined identifier a document is upserted +// by, or "" when the document carries none. The field location varies by kind, +// per the "Asset identifiers and idempotent upsert" table in the command +// reference: +// +// - Dashboard: metadata.dash0Extensions.id +// - CheckRule: top-level id +// - Dash0NotificationChannel: dash0.com/origin +// - Dash0SpamFilter, Dash0Team: dash0.com/origin, then dash0.com/id +// - everything else (PersesDashboard, PrometheusRule, SyntheticCheck, View): +// dash0.com/id +// +// Each kind reads only the field its Import helper actually upserts by. A +// fallback onto some other field would return an identifier no live asset can +// match, which --since would then "delete" to a 404 and report as already +// deleted -- returning "" instead routes the document to the NoIdentifier +// hard-fail. Only teams and spam filters genuinely accept either field. +func ExtractIdentifier(data []byte) (string, error) { + var probe identifierProbe + if err := sigsyaml.Unmarshal(data, &probe); err != nil { + return "", fmt.Errorf("failed to extract identifier: %w", err) + } + + origin := probe.Metadata.Labels["dash0.com/origin"] + + switch normalizeKindForIdentifier(probe.Kind) { + case "dashboard": + return probe.Metadata.Dash0Extensions.ID, nil + case "checkrule": + return probe.ID, nil + case "notificationchannel": + return origin, nil + case "spamfilter", "team": + if origin != "" { + return origin, nil + } + return probe.Metadata.Labels["dash0.com/id"], nil + default: + return probe.Metadata.Labels["dash0.com/id"], nil + } +} + +func normalizeKindForIdentifier(kind string) string { + k := strings.ToLower(strings.ReplaceAll(kind, "-", "")) + k = strings.ReplaceAll(k, "_", "") + return strings.TrimPrefix(k, "dash0") +} diff --git a/internal/asset/identifier_test.go b/internal/asset/identifier_test.go new file mode 100644 index 00000000..2e61819e --- /dev/null +++ b/internal/asset/identifier_test.go @@ -0,0 +1,48 @@ +package asset + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestExtractIdentifier_ReadsOnlyTheKindsUpsertField is a regression test for +// silent no-op deletions. ExtractIdentifier used to fall back across fields, +// so a View carrying only dash0.com/origin reported that origin as its +// identifier -- but ImportView upserts by dash0.com/id alone and the CLI +// strips origin outbound, so nothing live ever had it. --since then recorded +// the asset, deleted by an identifier that matches nothing, got a 404, and +// printed "was already deleted" while the asset stayed live. Returning "" +// instead routes it to the NoIdentifier hard-fail written for this case. +// +// Only Dash0Team and Dash0SpamFilter genuinely accept either field. +func TestExtractIdentifier_ReadsOnlyTheKindsUpsertField(t *testing.T) { + for _, tc := range []struct { + name string + doc string + want string + }{ + {"View ignores origin", "kind: View\nmetadata:\n labels:\n dash0.com/origin: o\n", ""}, + {"SyntheticCheck ignores origin", "kind: SyntheticCheck\nmetadata:\n labels:\n dash0.com/origin: o\n", ""}, + {"PersesDashboard ignores origin", "kind: PersesDashboard\nmetadata:\n labels:\n dash0.com/origin: o\n", ""}, + {"PrometheusRule ignores origin", "kind: PrometheusRule\nmetadata:\n labels:\n dash0.com/origin: o\n", ""}, + {"Dashboard ignores the id label", "kind: Dashboard\nmetadata:\n labels:\n dash0.com/id: i\n", ""}, + {"CheckRule ignores the id label", "kind: CheckRule\nmetadata:\n labels:\n dash0.com/id: i\n", ""}, + {"Dash0NotificationChannel ignores id", "kind: Dash0NotificationChannel\nmetadata:\n labels:\n dash0.com/id: i\n", ""}, + + {"Dash0Team falls back to id", "kind: Dash0Team\nmetadata:\n labels:\n dash0.com/id: i\n", "i"}, + {"Dash0SpamFilter falls back to id", "kind: Dash0SpamFilter\nmetadata:\n labels:\n dash0.com/id: i\n", "i"}, + {"Dash0Team prefers origin", "kind: Dash0Team\nmetadata:\n labels:\n dash0.com/id: i\n dash0.com/origin: o\n", "o"}, + + {"View reads the id label", "kind: View\nmetadata:\n labels:\n dash0.com/id: i\n", "i"}, + {"Dashboard reads dash0Extensions.id", "kind: Dashboard\nmetadata:\n dash0Extensions:\n id: i\n", "i"}, + {"CheckRule reads the top-level id", "kind: CheckRule\nid: i\n", "i"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := ExtractIdentifier([]byte(tc.doc)) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/internal/asset/kind.go b/internal/asset/kind.go index 21fd2cc3..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" @@ -31,6 +52,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) + } + } +} diff --git a/internal/asset/prometheusrule.go b/internal/asset/prometheusrule.go index 2ca52302..f613492f 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,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. @@ -38,6 +67,30 @@ func ParseCheckRules(data []byte) ([]*dash0api.PrometheusAlertRule, 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 { @@ -47,22 +100,173 @@ 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 + } + if err := CheckAlertNameCollisions(names); err != nil { + return err + } + multiAlert := len(names) > 1 + for i, name := range names { + if i >= len(rules) { + return nil + } + 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 +} + +// CheckAlertNameCollisions validates that no two alerting rules in the same +// PrometheusRule CRD would derive the identical check-rule id. +// DeriveAlertCheckRuleID folds each alert's composed name through slugify, +// which collapses punctuation differences (e.g. "High CPU" and "High_CPU" +// both fold to "high-cpu") -- if that happens, both alerts would upsert to +// the identical derived id, silently overwriting each other exactly like +// the collision this derivation exists to prevent in the first place. +// +// Called both from composePrometheusRuleNames (so apply's actual dispatch +// and check-rules create/update, which both go through ParseCheckRules, +// refuse the CRD) and from apply's own pre-flight validatePrometheusRule +// (so a multi-document apply run fails before any document is applied, not +// only once this specific CRD's turn comes up mid-run). +func CheckAlertNameCollisions(names []PrometheusAlertName) error { + if len(names) < 2 { + return nil + } + seenBySlug := make(map[string]PrometheusAlertName, len(names)) + for _, name := range names { + slug := slugify(name.CheckRuleName()) + if existing, ok := seenBySlug[slug]; ok { + return fmt.Errorf("alerting rules %q and %q derive the same check-rule id once slugified (%q); rename one of them to make them distinguishable", existing.CheckRuleName(), name.CheckRuleName(), slug) + } + seenBySlug[slug] = name + } + 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. +// +// Exported so --since's whole-CRD deletion dispatch (internal/apply/since.go) +// can compute the exact same derived id per alert when the whole CRD is +// removed: the check rules that actually exist server-side for a +// multi-alert CRD live at these derived ids, never at the literal shared +// id, so deleting the CRD must target each of them individually rather +// than the shared id alone. +func DeriveAlertCheckRuleID(sharedID, composedName string) string { + return sharedID + "--" + slugify(composedName) +} + +// CheckRuleIDsOccupiedByCRD returns the check-rule ids a CRD's alerts live at: +// the CRD's literal identifier for zero or one alert, each alert's derived id +// for two or more. ids[i] is alerts[i]'s address, which --since's diff needs. +func CheckRuleIDsOccupiedByCRD(identifier string, alerts []PrometheusAlertName) []string { + if len(alerts) <= 1 { + return []string{identifier} + } + ids := make([]string, len(alerts)) + for i, alertName := range alerts { + ids[i] = DeriveAlertCheckRuleID(identifier, alertName.CheckRuleName()) + } + return ids +} + +// 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"). +// +// This intentionally does not attempt to make its output collision-free on +// its own (e.g. "High CPU" and "High_CPU" both fold to "high-cpu") -- +// DeriveAlertCheckRuleID's caller is expected to combine this with a +// distinguishing prefix. It is not, on its own, a substitute for choosing +// alert names that remain distinct once punctuation is folded away. +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. +// +// 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) ([]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 } - i := 0 - for _, group := range crd.Spec.Groups { - for _, rule := range group.Rules { - if rule.Alert == nil || *rule.Alert == "" { + var names []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, 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..4fa3d517 100644 --- a/internal/asset/prometheusrule_test.go +++ b/internal/asset/prometheusrule_test.go @@ -59,6 +59,220 @@ 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_CollidingSlugsAreRejected is a regression test for a +// bug where slugify's punctuation folding let two differently-punctuated +// alert names in the same multi-alert CRD derive the identical check-rule +// id (e.g. "High CPU" and "High_CPU" both fold to "high-cpu") -- silently +// reopening the exact overwrite bug DeriveAlertCheckRuleID exists to +// prevent, since the derived ids would then collide with each other the +// same way the CRD's shared id used to collide across all of its alerts. +func TestParseCheckRules_CollidingSlugsAreRejected(t *testing.T) { + crd := []byte(`apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: app-rules + labels: + dash0.com/id: app-rules +spec: + groups: + - name: g + rules: + - alert: High CPU + expr: cpu > 0.9 + - alert: High_CPU + expr: cpu > 0.8 +`) + _, err := ParseCheckRules(crd) + require.Error(t, err) + assert.Contains(t, err.Error(), "g - High CPU") + assert.Contains(t, err.Error(), "g - High_CPU") +} + +// TestCheckAlertNameCollisions_SingleAlertNeverCollides pins that the +// collision check is a no-op for zero or one alert -- there is nothing to +// collide with. +func TestCheckAlertNameCollisions_SingleAlertNeverCollides(t *testing.T) { + require.NoError(t, CheckAlertNameCollisions(nil)) + require.NoError(t, CheckAlertNameCollisions([]PrometheusAlertName{{GroupName: "g", AlertName: "A"}})) +} + +// TestCheckAlertNameCollisions_DistinctNamesDoNotCollide pins the negative +// case: alerts whose composed names remain distinct after slugification +// must not be rejected. +func TestCheckAlertNameCollisions_DistinctNamesDoNotCollide(t *testing.T) { + err := CheckAlertNameCollisions([]PrometheusAlertName{ + {GroupName: "g", AlertName: "HighErrorRate"}, + {GroupName: "g", AlertName: "DiskFull"}, + }) + require.NoError(t, err) +} + +// 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 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/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) +} diff --git a/internal/git/diff.go b/internal/git/diff.go new file mode 100644 index 00000000..ad42c95b --- /dev/null +++ b/internal/git/diff.go @@ -0,0 +1,199 @@ +package git + +import ( + "slices" + "sort" + + "github.com/dash0hq/dash0-cli/internal/asset" +) + +// 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 + // 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 + // PrometheusAlerts carries every alerting rule the CRD had at the + // "before" snapshot, when Kind is "prometheusrule" (nil for every other + // kind, and for a CRD with zero or one alert). A CRD with two or more + // alerts has each alert's real check rule living at its own derived id + // (asset.DeriveAlertCheckRuleID), not at the CRD's literal Identifier -- + // the delete dispatch needs this list to compute and delete each of + // those derived ids individually, since Identifier alone only ever + // named a check rule for a single-alert CRD. + PrometheusAlerts []asset.PrometheusAlertName +} + +// 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 + asset.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 + } + deletion := Deletion{ + Kind: key.Kind, + Identifier: key.Identifier, + Path: path, + SpamFilterUsesOrigin: before.SpamFilterUsesOriginByIdentifier[key.Identifier], + } + if key.Kind == "prometheusrule" { + deletion.PrometheusAlerts = before.PrometheusAlertsByIdentifier[key.Identifier] + } + plan.ByIdentifier = append(plan.ByIdentifier, deletion) + } + + // 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}], + }) + } + + 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[asset.PrometheusAlertName]bool, len(afterAlerts)) + for _, name := range afterAlerts { + afterSet[name] = true + } + // An alert's id depends on its sibling count, so moving the CRD between + // one alert and many relocates a survivor. apply writes the new + // address and leaves the old one live. + occupiedNow := asset.CheckRuleIDsOccupiedByCRD(identifier, afterAlerts) + occupiedBefore := asset.CheckRuleIDsOccupiedByCRD(identifier, beforeAlerts) + for i, name := range beforeAlerts { + if !afterSet[name] { + plan.AlertsByName = append(plan.AlertsByName, AlertDeletion{ + CRDIdentifier: identifier, + PrometheusAlertName: name, + }) + continue + } + if id := occupiedBefore[i]; !slices.Contains(occupiedNow, id) { + plan.ByIdentifier = append(plan.ByIdentifier, Deletion{ + Kind: "checkrule", + Identifier: id, + 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 + } + return plan.ByIdentifier[i].Identifier < plan.ByIdentifier[j].Identifier + }) + + 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..f96778cb --- /dev/null +++ b/internal/git/diff_test.go @@ -0,0 +1,378 @@ +package git + +import ( + "testing" + + "github.com/dash0hq/dash0-cli/internal/asset" + "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"] = []asset.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"] = []asset.PrometheusAlertName{ + {GroupName: "g", AlertName: "A"}, + } + + plan := Diff(before, after) + require.Len(t, plan.AlertsByName, 1) + assert.Equal(t, "crd-1", plan.AlertsByName[0].CRDIdentifier) + assert.Equal(t, "g - B", plan.AlertsByName[0].CheckRuleName()) + // The survivor moves from its derived id back to the literal one, so the + // derived id is abandoned and apply's write there would leave a duplicate. + require.Len(t, plan.ByIdentifier, 1) + assert.Equal(t, Deletion{Kind: "checkrule", Identifier: "crd-1--g-a", Path: "rules.yaml"}, plan.ByIdentifier[0]) +} + +// 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" + before.PrometheusAlertsByIdentifier["crd-1"] = []asset.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") +} + +// TestDiff_PrometheusWholeCRDDeletionCarriesAlertNames is a regression test +// for a bug where a whole-CRD deletion's Deletion entry carried only the +// CRD's own identifier, with no way for the delete dispatch to know the +// CRD's alerts each got their own derived check-rule id (see +// asset.DeriveAlertCheckRuleID) once the CRD has two or more of them -- +// dispatch had no choice but to (wrongly) delete by the literal identifier +// alone, missing every alert's real check rule entirely. +func TestDiff_PrometheusWholeCRDDeletionCarriesAlertNames(t *testing.T) { + before := newSnapshot() + before.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules.yaml" + before.PrometheusAlertsByIdentifier["crd-1"] = []asset.PrometheusAlertName{ + {GroupName: "g", AlertName: "A"}, + {GroupName: "g", AlertName: "B"}, + } + + after := newSnapshot() + + plan := Diff(before, after) + require.Len(t, plan.ByIdentifier, 1) + assert.Equal(t, []asset.PrometheusAlertName{ + {GroupName: "g", AlertName: "A"}, + {GroupName: "g", AlertName: "B"}, + }, plan.ByIdentifier[0].PrometheusAlerts) +} + +// TestDiff_NonPrometheusRuleDeletionHasNoAlerts pins that PrometheusAlerts +// is only ever populated for kind "prometheusrule", never accidentally +// carried over for an unrelated kind that happens to share PrometheusAlertsByIdentifier's +// map (it doesn't, but the zero-value contract is worth pinning directly). +func TestDiff_NonPrometheusRuleDeletionHasNoAlerts(t *testing.T) { + before := newSnapshot() + before.Identifiers[IdentifierKey{Kind: "dashboard", Identifier: "id-1"}] = "dashboard.yaml" + + after := newSnapshot() + + plan := Diff(before, after) + require.Len(t, plan.ByIdentifier, 1) + assert.Empty(t, plan.ByIdentifier[0].PrometheusAlerts) +} + +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"] = []asset.PrometheusAlertName{ + {GroupName: "g", AlertName: "B"}, + {GroupName: "g", AlertName: "A"}, + } + before.PrometheusAlertsByIdentifier["crd-2"] = []asset.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"] = []asset.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) +} + +// TestDiff_PrometheusAlertThresholdCrossing_OneToMany covers the other +// direction: both alerts move to derived ids, abandoning the literal one. +func TestDiff_PrometheusAlertThresholdCrossing_OneToMany(t *testing.T) { + before := newSnapshot() + before.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules.yaml" + before.PrometheusAlertsByIdentifier["crd-1"] = []asset.PrometheusAlertName{ + {GroupName: "g", AlertName: "A"}, + } + + after := newSnapshot() + after.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules.yaml" + after.PrometheusAlertsByIdentifier["crd-1"] = []asset.PrometheusAlertName{ + {GroupName: "g", AlertName: "A"}, + {GroupName: "g", AlertName: "B"}, + } + + plan := Diff(before, after) + assert.Empty(t, plan.AlertsByName, "no alert was removed") + require.Len(t, plan.ByIdentifier, 1) + assert.Equal(t, Deletion{Kind: "checkrule", Identifier: "crd-1", Path: "rules.yaml"}, plan.ByIdentifier[0]) +} + +// TestDiff_PrometheusAlertNoAbandonedAddress pins the cases where no survivor +// changes address. +func TestDiff_PrometheusAlertNoAbandonedAddress(t *testing.T) { + for _, tc := range []struct { + name string + before, after []asset.PrometheusAlertName + }{ + { + name: "three alerts down to two, both derived", + before: []asset.PrometheusAlertName{{GroupName: "g", AlertName: "A"}, {GroupName: "g", AlertName: "B"}, {GroupName: "g", AlertName: "C"}}, + after: []asset.PrometheusAlertName{{GroupName: "g", AlertName: "A"}, {GroupName: "g", AlertName: "B"}}, + }, + { + // Survives as recording-rule-only; the alert goes by name. + name: "one alert down to none, both literal", + before: []asset.PrometheusAlertName{{GroupName: "g", AlertName: "A"}}, + after: nil, + }, + { + // Nothing was at the literal id to reclaim. + name: "no alerts up to two", + before: nil, + after: []asset.PrometheusAlertName{{GroupName: "g", AlertName: "A"}, {GroupName: "g", AlertName: "B"}}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + before := newSnapshot() + before.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules.yaml" + before.PrometheusAlertsByIdentifier["crd-1"] = tc.before + + after := newSnapshot() + after.Identifiers[IdentifierKey{Kind: "prometheusrule", Identifier: "crd-1"}] = "rules.yaml" + after.PrometheusAlertsByIdentifier["crd-1"] = tc.after + + assert.Empty(t, Diff(before, after).ByIdentifier) + }) + } +} diff --git a/internal/git/plumbing.go b/internal/git/plumbing.go new file mode 100644 index 00000000..6f61c58b --- /dev/null +++ b/internal/git/plumbing.go @@ -0,0 +1,242 @@ +// 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" + "sync" + + "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. +// +// Its own error is deliberately unadorned (no "failed to determine +// repository root for %s" wrapping): callers that need a clean, single-line +// message for the common "not a git repository at all" case should check +// IsNotAGitRepository(err) themselves and build their own message from +// scratch, rather than trying to make a wrapped chain of "failed to +// determine..." / "git rev-parse ...: exit status 128 (stderr: fatal: not +// a git repository...)" read well. r.Dir is included so a caller that does +// want the raw error for anything else still knows which directory failed. +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 +} + +// IsNotAGitRepository reports whether err (as returned by Root, or any +// other Repo method) is git's own "fatal: not a git repository (or any of +// the parent directories): .git" failure -- the common case of running +// --since against a directory that was never a git repository at all, as +// opposed to some other infrastructure failure. Matched by substring +// against git's own stable, documented wording, since git's rev-parse +// exit code (128) is a generic "fatal error" shared by many unrelated +// failures and can't distinguish this case on its own. +func IsNotAGitRepository(err error) bool { + return err != nil && strings.Contains(err.Error(), "not a git repository") +} + +// 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 +} + +// readFilesAtRef reads every path at ref with bounded concurrency. One +// cat-file subprocess per file dominates snapshot build time (~9ms of spawn +// each), so a few hundred assets otherwise cost seconds of process startup. +func (r Repo) readFilesAtRef(ctx context.Context, ref string, paths []string) ([][]byte, error) { + contents := make([][]byte, len(paths)) + errs := make([]error, len(paths)) + sem := make(chan struct{}, 16) + var wg sync.WaitGroup + for i, path := range paths { + wg.Go(func() { + sem <- struct{}{} + defer func() { <-sem }() + contents[i], errs[i] = r.ReadFileAtRef(ctx, ref, path) + }) + } + wg.Wait() + for _, err := range errs { + if err != nil { + return nil, err + } + } + return contents, nil +} + +// IsTreeAtRef runs `git cat-file -t :` and reports whether path +// was a directory (a "tree" object) at ref, as opposed to a file (a "blob"). +// path == "" means the repository root itself, which is always a tree. +// +// Used to recover whether a --since target that no longer exists on disk at +// all was a single file or a directory the last time it did exist, so +// dry-run rendering can group its output the same way it would have while +// the target still existed, instead of defaulting to one shape regardless. +func (r Repo) IsTreeAtRef(ctx context.Context, ref, path string) (bool, error) { + if path == "" { + return true, nil + } + out, err := r.run(ctx, "cat-file", "-t", ref+":"+path) + if err != nil { + return false, fmt.Errorf("failed to determine object type of %s at %s: %w", path, ref, err) + } + return strings.TrimSpace(string(out)) == "tree", nil +} + +// HasSkipWorktreeFiles reports whether any tracked file under scope carries +// git's skip-worktree bit, which sparse-checkout sets on everything outside +// the cone. Such a file is in the commit but not on disk, so the ref side of a +// --since diff sees it and the disk side does not, making it look deleted. +func (r Repo) HasSkipWorktreeFiles(ctx context.Context, scope string) (bool, error) { + args := []string{"ls-files", "-t", "-z"} + if scope != "" { + args = append(args, "--", scope) + } + out, err := r.run(ctx, args...) + if err != nil { + return false, fmt.Errorf("failed to list tracked files under %s: %w", scope, err) + } + // Entries are " " separated by NUL; S is skip-worktree. + return bytes.HasPrefix(out, []byte("S ")) || bytes.Contains(out, []byte("\x00S ")), nil +} + +// ListYAMLFilesAtRef runs `git ls-tree -r -z --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) { + // -z is load-bearing: without it git C-quotes a non-ASCII path + // ("a/caf\303\251.yaml") and the trailing quote fails IsYAMLFile. + args := []string{"ls-tree", "-r", "-z", "--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.TrimRight(string(out), "\x00"), "\x00") { + 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..269007e1 --- /dev/null +++ b/internal/git/plumbing_test.go @@ -0,0 +1,248 @@ +package git + +import ( + "context" + "errors" + "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 TestIsTreeAtRef_RepoRoot(t *testing.T) { + repo := testRepo(t) + isTree, err := repo.IsTreeAtRef(context.Background(), "HEAD", "") + require.NoError(t, err) + assert.True(t, isTree, "the repository root itself is always a tree") +} + +func TestIsTreeAtRef_Directory(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "dashboards/one.yaml", "kind: Dashboard\n") + commitAll(t, repo.Dir, "add directory") + + isTree, err := repo.IsTreeAtRef(context.Background(), "HEAD", "dashboards") + require.NoError(t, err) + assert.True(t, isTree) +} + +func TestIsTreeAtRef_File(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "dashboard.yaml", "kind: Dashboard\n") + commitAll(t, repo.Dir, "add file") + + isTree, err := repo.IsTreeAtRef(context.Background(), "HEAD", "dashboard.yaml") + require.NoError(t, err) + assert.False(t, isTree) +} + +func TestIsTreeAtRef_NonexistentPathIsError(t *testing.T) { + repo := testRepo(t) + _, err := repo.IsTreeAtRef(context.Background(), "HEAD", "never-existed.yaml") + require.Error(t, err) +} + +func TestIsAncestor_UnknownRefIsError(t *testing.T) { + repo := testRepo(t) + _, err := repo.IsAncestor(context.Background(), "does-not-exist", "HEAD") + require.Error(t, err) +} + +// TestRoot_NotAGitRepository pins IsNotAGitRepository's contract: Root's +// error, run against a plain directory that was never a git repository at +// all, must be recognized by IsNotAGitRepository so callers can build a +// clean, single-line message instead of propagating git's own nested +// "failed to determine repository root for ...: git rev-parse +// --show-toplevel: exit status 128 (stderr: fatal: not a git repository +// ...)" chain verbatim. +func TestRoot_NotAGitRepository(t *testing.T) { + notARepo := Repo{Dir: t.TempDir()} + _, err := notARepo.Root(context.Background()) + require.Error(t, err) + assert.True(t, IsNotAGitRepository(err), "expected IsNotAGitRepository to recognize %v", err) +} + +// TestIsNotAGitRepository_OtherErrorsReturnFalse pins that +// IsNotAGitRepository doesn't fire on an unrelated error, so callers don't +// mistakenly swap in the "not a git repository" message for some other +// infrastructure failure. +func TestIsNotAGitRepository_OtherErrorsReturnFalse(t *testing.T) { + assert.False(t, IsNotAGitRepository(nil)) + assert.False(t, IsNotAGitRepository(errors.New("some other failure"))) +} + +// TestListYAMLFilesAtRef_PathsGitWouldMangle covers the paths that only +// survive with -z. Non-ASCII is the reported regression: git C-quoted it, the +// trailing quote failed the extension check, and the deletion went undetected. +// The other two are why -z beats core.quotePath=false, which fixes neither. +// +// ListYAMLFilesAtRef sorts, so each case's files double as its expectation. +func TestListYAMLFilesAtRef_PathsGitWouldMangle(t *testing.T) { + for _, tc := range []struct { + name string + files []string + }{ + {"non-ASCII bytes, which git C-quotes", []string{"café.yaml", "plain.yaml", "日本語/ビュー.yaml"}}, + {"a newline, which splits one path into two", []string{"we\nird.yaml"}}, + {"a leading space, which per-line trimming eats", []string{" lead.yaml"}}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := testRepo(t) + for _, f := range tc.files { + writeFile(t, repo.Dir, f, "kind: View\n") + } + commitAll(t, repo.Dir, "add files") + + files, err := repo.ListYAMLFilesAtRef(context.Background(), "HEAD", "") + require.NoError(t, err) + assert.Equal(t, tc.files, files) + }) + } +} + +// TestHasSkipWorktreeFiles checks per scope, not repo-wide: a sparse cone +// elsewhere in a monorepo must not block a --since run whose own target is +// fully materialized. +func TestHasSkipWorktreeFiles(t *testing.T) { + for _, tc := range []struct { + scope string + want bool + }{ + {"", true}, + {"drop", true}, + {"keep", false}, + } { + t.Run("scope="+tc.scope, func(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "keep/k.yaml", "kind: View\n") + writeFile(t, repo.Dir, "drop/d.yaml", "kind: View\n") + commitAll(t, repo.Dir, "add files") + runGit(t, repo.Dir, "update-index", "--skip-worktree", "drop/d.yaml") + + got, err := repo.HasSkipWorktreeFiles(context.Background(), tc.scope) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/internal/git/ref.go b/internal/git/ref.go new file mode 100644 index 00000000..a5da8530 --- /dev/null +++ b/internal/git/ref.go @@ -0,0 +1,142 @@ +package git + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" +) + +// 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 +} + +// simpleAncestorRefPattern matches a ref written as "~N" or +// "^N", including the bare "~"/"^" shorthand for N=1 +// (an empty digit group). The base is captured non-greedily so a chained +// expression like "abc~2~3" splits at its last separator, matching git's +// own left-to-right evaluation. +var simpleAncestorRefPattern = regexp.MustCompile(`^(.+?)[~^](\d*)$`) + +// ExplainUnresolvableRef returns a best-effort, more specific reason why +// ref (already known to be RefUnresolvable) failed to resolve, or "" if it +// can't tell. Today it recognizes exactly one shape: a simple "~N" or +// "^N" expression (or the bare "~"/"^" shorthand for N=1) whose base +// resolves fine, but whose own history has fewer than N+1 commits -- e.g. +// --since HEAD~1 in a repository with only one commit. That's a materially +// different situation from a typo'd ref or a too-shallow clone (the two +// reasons the generic RefUnresolvable message suggests), and it's often +// the very first wall someone hits setting up a fresh repo to try --since +// against. Deliberately narrow: no attempt is made to parse the rest of +// git's revision syntax (^{...}, @{...}, a mix of ~ and ^, etc.) -- those +// fall through to the generic message unchanged. +func (r Repo) ExplainUnresolvableRef(ctx context.Context, ref string) string { + m := simpleAncestorRefPattern.FindStringSubmatch(ref) + if m == nil { + return "" + } + base, digits := m[1], m[2] + n := 1 + if digits != "" { + parsed, err := strconv.Atoi(digits) + if err != nil { + return "" + } + n = parsed + } + + if _, err := r.resolveCommit(ctx, base); err != nil { + // base itself doesn't resolve either -- a genuine typo (in base, or + // in the whole ref), not an insufficient-history case. + return "" + } + out, err := r.run(ctx, "rev-list", "--count", base) + if err != nil { + return "" + } + count, err := strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return "" + } + if n < count { + // There IS enough history; something else is wrong with ref, and + // the generic message's suggestions are as good a guess as any. + return "" + } + + commitWord := "commit" + if count != 1 { + commitWord += "s" + } + return fmt.Sprintf("%q has only %d %s of history, not enough for %q to resolve %d commit%s further back", base, count, commitWord, ref, n, pluralSuffix(n)) +} + +func pluralSuffix(n int) string { + if n == 1 { + return "" + } + return "s" +} diff --git a/internal/git/ref_test.go b/internal/git/ref_test.go new file mode 100644 index 00000000..dd893bc5 --- /dev/null +++ b/internal/git/ref_test.go @@ -0,0 +1,144 @@ +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) +} + +// TestExplainUnresolvableRef_InsufficientHistory is a regression test for a +// bug where --since HEAD~1 against a fresh, single-commit repository (the +// first thing many people try when setting up a --since test or demo repo) +// was told to check for a typo or a too-shallow clone -- neither applies: +// there is simply no earlier commit yet. +func TestExplainUnresolvableRef_InsufficientHistory(t *testing.T) { + repo := testRepo(t) // exactly one commit + + reason := repo.ExplainUnresolvableRef(context.Background(), "HEAD~1") + assert.Contains(t, reason, "1 commit") + assert.Contains(t, reason, `"HEAD~1"`) +} + +// TestExplainUnresolvableRef_BareTildeShorthand pins that the bare "~" +// suffix (shorthand for "~1") is recognized the same as an explicit "~1". +func TestExplainUnresolvableRef_BareTildeShorthand(t *testing.T) { + repo := testRepo(t) + + reason := repo.ExplainUnresolvableRef(context.Background(), "HEAD~") + assert.Contains(t, reason, "1 commit") +} + +// TestExplainUnresolvableRef_CaretSuffix pins that "^N" (not just "~N") is +// also recognized. +func TestExplainUnresolvableRef_CaretSuffix(t *testing.T) { + repo := testRepo(t) + + reason := repo.ExplainUnresolvableRef(context.Background(), "HEAD^1") + assert.Contains(t, reason, "1 commit") +} + +// TestExplainUnresolvableRef_EnoughHistoryReturnsEmpty pins the negative +// case: when the base actually has enough history, there's nothing to +// explain -- the ref would have resolved, so callers should never reach +// this function for it in practice, but the function's own logic must +// still not fabricate a reason if asked anyway. +func TestExplainUnresolvableRef_EnoughHistoryReturnsEmpty(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "f.yaml", "kind: Dashboard\n") + commitAll(t, repo.Dir, "second commit") + + assert.Empty(t, repo.ExplainUnresolvableRef(context.Background(), "HEAD~1")) +} + +// TestExplainUnresolvableRef_TypoBaseReturnsEmpty pins that a base which +// doesn't resolve at all (a genuine typo, not an insufficient-history +// case) yields no explanation, leaving the caller to fall back to the +// generic typo-or-shallow-clone message. +func TestExplainUnresolvableRef_TypoBaseReturnsEmpty(t *testing.T) { + repo := testRepo(t) + + assert.Empty(t, repo.ExplainUnresolvableRef(context.Background(), "totally-bogus-base~1")) +} + +// TestExplainUnresolvableRef_NonMatchingShapeReturnsEmpty pins that a ref +// with no trailing ~N/^N shape at all (e.g. a plain typo'd branch name) +// yields no explanation. +func TestExplainUnresolvableRef_NonMatchingShapeReturnsEmpty(t *testing.T) { + repo := testRepo(t) + + assert.Empty(t, repo.ExplainUnresolvableRef(context.Background(), "totally-bogus-ref")) +} + +// 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..0ff45830 --- /dev/null +++ b/internal/git/snapshot.go @@ -0,0 +1,307 @@ +package git + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + dash0yaml "github.com/dash0hq/dash0-api-client-go/yaml" + "github.com/dash0hq/dash0-cli/internal/asset" + "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][]asset.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 + // 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 + + // RawContent maps every file path scanned to its raw content, as read at + // that point in time. Populated only by BuildSnapshotFromRef (nil/empty + // from BuildSnapshotFromDisk, which has no caller that needs it): every + // file --since's deletion detection ever names came from the "before" + // (ref) snapshot, so retaining the content already read while building + // it lets a caller resolve a deletion's display name (see + // resolveDeletionNames in internal/apply/since.go) from memory instead + // of shelling out to `git cat-file` a second time for the same blob. + RawContent map[string][]byte +} + +func newSnapshot() Snapshot { + return Snapshot{ + Identifiers: map[IdentifierKey]string{}, + NoIdentifier: map[string]NoIdentifierDoc{}, + PrometheusAlertsByIdentifier: map[string][]asset.PrometheusAlertName{}, + PrometheusRecordingRoleByIdentifier: map[string]bool{}, + SpamFilterUsesOriginByIdentifier: map[string]bool{}, + Paths: map[string]bool{}, + RawContent: map[string][]byte{}, + } +} + +// 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 + } + + contents, err := repo.readFilesAtRef(ctx, ref, files) + if err != nil { + return Snapshot{}, err + } + + snap := newSnapshot() + for i, path := range files { + snap.Paths[path] = true + snap.RawContent[path] = contents[i] + if err := ingestDocuments(&snap, path, contents[i]); 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. +// +// 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) + } + + 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 := asset.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 + + 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" { + 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..f80f8f5a --- /dev/null +++ b/internal/git/snapshot_test.go @@ -0,0 +1,223 @@ +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 +// asset.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) +} + +// TestBuildSnapshotFromRef_RawContentPopulated pins that RawContent carries +// the exact bytes read for every scanned file, keyed the same way Paths and +// Identifiers are (repo-relative, no doc-index suffix). This is what lets +// resolveDeletionNames (internal/apply/since.go) resolve a deletion's +// display name from the "before" snapshot already built here, instead of +// shelling out to `git cat-file` a second time for the same blob. +func TestBuildSnapshotFromRef_RawContentPopulated(t *testing.T) { + repo := testRepo(t) + writeFile(t, repo.Dir, "dashboard.yaml", dashboardYAML) + commitAll(t, repo.Dir, "add asset") + + snap, err := BuildSnapshotFromRef(context.Background(), repo, "HEAD", "") + require.NoError(t, err) + + require.Contains(t, snap.RawContent, "dashboard.yaml") + assert.Equal(t, dashboardYAML, string(snap.RawContent["dashboard.yaml"])) +} + +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") +} + +// 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) + + 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/internal/skill/content/SKILL.md b/internal/skill/content/SKILL.md index a8d8d3ca..2372fe9b 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 @@ -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: `