From 4bf8fa6e0903d914d0880a9ff7300629c411d361 Mon Sep 17 00:00:00 2001 From: Ilmars Janis Bluzmanis <9987548+darksworm@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:40:47 +0200 Subject: [PATCH 1/4] test: drive the real ArgoCD from the TUI and verify server-side --- Makefile | 7 +- argocd/fixtures/README.md | 129 ++++++++ argocd/fixtures/apps-sync-options.yaml | 69 ++++ .../prune-confirm/configmap-keep.yaml | 6 + .../prune-confirm/configmap-orphan.yaml | 11 + .../manifests/prune/configmap-keep.yaml | 7 + .../manifests/prune/configmap-orphan.yaml | 9 + .../manifests/schema-error/deployment.yaml | 22 ++ argocd/fixtures/seed-sync-fixtures.sh | 118 +++++++ e2e/real_argocd_test.go | 295 ++++++++++++++++++ 10 files changed, 672 insertions(+), 1 deletion(-) create mode 100644 argocd/fixtures/README.md create mode 100644 argocd/fixtures/apps-sync-options.yaml create mode 100644 argocd/fixtures/manifests/prune-confirm/configmap-keep.yaml create mode 100644 argocd/fixtures/manifests/prune-confirm/configmap-orphan.yaml create mode 100644 argocd/fixtures/manifests/prune/configmap-keep.yaml create mode 100644 argocd/fixtures/manifests/prune/configmap-orphan.yaml create mode 100644 argocd/fixtures/manifests/schema-error/deployment.yaml create mode 100755 argocd/fixtures/seed-sync-fixtures.sh create mode 100644 e2e/real_argocd_test.go diff --git a/Makefile b/Makefile index fc336107..77ba3b66 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -.PHONY: dev test unit e2e goldens \ +.PHONY: dev test unit e2e real-e2e goldens \ k3d-start k3d-stop k3d-restart k3d-delete \ argocd-up argocd-down argocd-restart \ argocd-portforward argocd-portforward-stop \ @@ -41,6 +41,11 @@ unit: e2e: go test -tags e2e ./e2e -v -count=1 -parallel $(PARALLEL) +# Run the horizontal e2e suite against the local ArgoCD (see argocd/fixtures). +# Requires: make argocd-up && make argocd-git-daemon && ./argocd/fixtures/seed-sync-fixtures.sh +real-e2e: + ARGONAUT_REAL_ARGOCD=1 go test -tags e2e ./e2e -run TestRealArgoCD -v -count=1 + # Regenerate golden snapshots for app tests. goldens: UPDATE_GOLDEN=1 go test ./cmd/app -run TestGolden_ -v diff --git a/argocd/fixtures/README.md b/argocd/fixtures/README.md new file mode 100644 index 00000000..461e9eb5 --- /dev/null +++ b/argocd/fixtures/README.md @@ -0,0 +1,129 @@ +# Sync-option fixtures (draft) + +Three small Argo CD Applications for exercising sync options against the local +k3d Argo CD. Nothing here is in the argonaut repo yet — these are drafts. + +```bash +make argocd-up +make argocd-git-daemon +./seed-sync-fixtures.sh +``` + +`seed-sync-fixtures.sh` reuses `scripts/seed-history.sh`'s mechanism: it builds a +throwaway git repo next to the argonaut checkout (`argonaut-sync-fixtures-repo`), +which the `git daemon` from `make argocd-git-daemon` exports, so the cluster +clones it as `git://host.k3d.internal/argonaut-sync-fixtures-repo` with no push +to any remote. Re-running the script rebuilds the repo and re-syncs all three +apps. The Applications carry no finalizer (matching `apps-hang.yaml`), so +deleting them leaves the ConfigMaps and namespaces behind; the next run adopts +them again, so the end state is right even though it is not a clean slate. + +If these move into the repo, `argocd/fixtures/` or `scripts/fixtures/` both work +— the script derives the daemon base path from `git rev-parse --show-toplevel`. +Outside a checkout, set `GIT_DAEMON_BASE_PATH`. + +## The fixtures + +| App | Namespace | What it demonstrates | +|---|---|---| +| `prune-demo` | `sync-prune-demo` | A live ConfigMap with no git counterpart — `sync --prune` really deletes something | +| `prune-confirm-demo` | `sync-confirm-demo` | `sync --prune` parks in `Running` awaiting confirmation | +| `schema-error-demo` | `sync-invalid-demo` | `sync --dry-run` fails with a validation message | + +All three follow the existing conventions: `project: default`, +`destination.server: https://kubernetes.default.svc`, manual sync +(`syncPolicy.automated: null`), `CreateNamespace=true` — same shape as +`argocd/apps-hang.yaml`. + +### 1. Something to prune + +The seed repo has two commits. The first contains `keep-me` **and** +`prune-me`; the second deletes `prune-me`. The script syncs the app at the +**first** commit, but the Application's `targetRevision` is `main`. So +`prune-me` is live in the cluster and absent from the tracked revision — Argo CD +marks the app OutOfSync and offers it as a prune candidate. + +```bash +argocd app sync prune-demo # stays OutOfSync, prune skipped +argocd app sync prune-demo --prune # deletes prune-me, app goes Synced +``` + +This "manifest set shrinks between commits" trick is the same one +`seed-history.sh` already uses for `manifests/service.yaml` (present only from +commit 2 onward), just run backwards. + +### 2. Prune stuck awaiting confirmation + +Same setup, plus `argocd.argoproj.io/sync-options: Prune=confirm` on the orphan +ConfigMap. A prune task has no target object, so Argo CD reads the sync option +off the **live** resource — which is why the annotation has to be present in the +commit the app is synced at, not added later. + +```bash +argocd app sync prune-confirm-demo --prune --timeout 60 # blocks +argocd app get prune-confirm-demo # operation phase: Running +argocd app confirm-deletion prune-confirm-demo # releases it +``` + +Notes: +- Without `--prune` the gate never triggers — the app just stays OutOfSync. +- Confirming is also possible from the UI ("Confirm Pruning") or by annotating + the **Application** with `argocd.argoproj.io/deletion-approved: `. + +### 3. Schema error + +`manifests/schema-error/deployment.yaml` says `reploicas` instead of `replicas`. +Argo CD's dry-run apply hits the API server, which rejects it: + +``` +Deployment in version "v1" cannot be handled as a Deployment: +strict decoding error: unknown field "spec.reploicas" +``` + +```bash +argocd app sync schema-error-demo --dry-run # SyncFailed, no cluster changes +argocd app sync schema-error-demo # same failure, recorded in status +``` + +The script leaves this app unsynced so the first sync attempt is the failing one. + +Use the **plain** sync when the failure has to be visible in argonaut: a real +sync definitely records `SyncFailed` in `status.operationState`, which is what +the TUI renders. Whether `--dry-run` persists an operation result there too, or +only prints to the CLI, was not verified — see below. + +## Version requirements + +- **`Prune=confirm` (fixture 2) needs Argo CD ≥ 2.14**, not 3.1 as the task + stated — upstream introduced it in the 2.14 release. This box currently runs + **v3.5.1**, so it is well covered. +- `setup-fixed.sh` installs from + `https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml`, + a **moving target**. It cannot pin a version, so the fixture's floor is a + documented assumption rather than an enforced one. It only breaks if `stable` + ever regresses below 2.14, which it will not. +- Fixtures 1 and 3 have no meaningful version floor. +- The local `argocd` CLI is v3.4.2, one minor behind the server. `app + confirm-deletion` is present in it (verified via `argocd app --help`). + +## Verified vs. assumed + +Verified against the running cluster: +- Server image `quay.io/argoproj/argocd:v3.5.1`; CLI `v3.4.2`. +- `kubectl apply --dry-run=server` rejects `spec.reploicas` with the strict + decoding error quoted above. (`--dry-run=client` silently accepts it — client + side validation is not enough, but Argo CD's sync does a server-side dry run.) +- `argocd app confirm-deletion` exists; `objRequiresPruneConfirmation` and + `WithPruneConfirmed` are present in the shipped binary. + +Assumed, not verified end to end: +- That the whole seed script runs green — it was syntax-checked only, never + executed, since that would mutate the live cluster and create a sibling repo + directory. +- The exact wording of the `argocd.argoproj.io/deletion-approved` annotation + against 3.5.1 (taken from upstream docs, not from this install). +- That `--prune` on `prune-confirm-demo` parks in `Running` rather than failing + outright. This is the documented behaviour but was not run here. +- That a **dry-run** sync writes `SyncFailed` into `status.operationState`. Only + the API server's rejection of the manifest was verified, not what Argo CD + persists. A plain sync is the safe variant for anything reading app status. diff --git a/argocd/fixtures/apps-sync-options.yaml b/argocd/fixtures/apps-sync-options.yaml new file mode 100644 index 00000000..529b5107 --- /dev/null +++ b/argocd/fixtures/apps-sync-options.yaml @@ -0,0 +1,69 @@ +# Three purpose-built fixtures for exercising sync options against the local +# k3d Argo CD. They all point at the local git daemon repo seeded by +# ./seed-sync-fixtures.sh, so nothing here needs a remote push. +# +# make argocd-up && make argocd-git-daemon +# ./seed-sync-fixtures.sh +# +# Manual sync only, matching argocd/apps-hang.yaml and the other demo apps. +--- +# 1) Something real to prune: `prune-me` is live but no longer in git. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: prune-demo + namespace: argocd +spec: + project: default + source: + repoURL: git://host.k3d.internal/argonaut-sync-fixtures-repo + targetRevision: main + path: prune + destination: + server: https://kubernetes.default.svc + namespace: sync-prune-demo + syncPolicy: + automated: null # Manual sync — the whole point is to drive prune by hand + syncOptions: + - CreateNamespace=true +--- +# 2) Prune gated on confirmation: `argocd app sync prune-confirm-demo --prune` +# parks in Running until `argocd app confirm-deletion prune-confirm-demo`. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: prune-confirm-demo + namespace: argocd +spec: + project: default + source: + repoURL: git://host.k3d.internal/argonaut-sync-fixtures-repo + targetRevision: main + path: prune-confirm + destination: + server: https://kubernetes.default.svc + namespace: sync-confirm-demo + syncPolicy: + automated: null # Manual sync + syncOptions: + - CreateNamespace=true +--- +# 3) Deliberate schema error: any sync (dry-run included) fails validation. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: schema-error-demo + namespace: argocd +spec: + project: default + source: + repoURL: git://host.k3d.internal/argonaut-sync-fixtures-repo + targetRevision: main + path: schema-error + destination: + server: https://kubernetes.default.svc + namespace: sync-invalid-demo + syncPolicy: + automated: null # Manual sync + syncOptions: + - CreateNamespace=true diff --git a/argocd/fixtures/manifests/prune-confirm/configmap-keep.yaml b/argocd/fixtures/manifests/prune-confirm/configmap-keep.yaml new file mode 100644 index 00000000..e7a5e769 --- /dev/null +++ b/argocd/fixtures/manifests/prune-confirm/configmap-keep.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: keep-me +data: + role: "permanent" diff --git a/argocd/fixtures/manifests/prune-confirm/configmap-orphan.yaml b/argocd/fixtures/manifests/prune-confirm/configmap-orphan.yaml new file mode 100644 index 00000000..9ce5cb8b --- /dev/null +++ b/argocd/fixtures/manifests/prune-confirm/configmap-orphan.yaml @@ -0,0 +1,11 @@ +# Same orphan trick as ../prune, plus the confirmation gate. A prune task has +# no target object, so Argo CD reads the sync option off the LIVE resource — +# the annotation must therefore be present at the commit the app is synced at. +apiVersion: v1 +kind: ConfigMap +metadata: + name: prune-me-with-confirmation + annotations: + argocd.argoproj.io/sync-options: Prune=confirm +data: + role: "orphan" diff --git a/argocd/fixtures/manifests/prune/configmap-keep.yaml b/argocd/fixtures/manifests/prune/configmap-keep.yaml new file mode 100644 index 00000000..af81d50e --- /dev/null +++ b/argocd/fixtures/manifests/prune/configmap-keep.yaml @@ -0,0 +1,7 @@ +# Stays in git for the whole fixture — the app should remain Synced on this one. +apiVersion: v1 +kind: ConfigMap +metadata: + name: keep-me +data: + role: "permanent" diff --git a/argocd/fixtures/manifests/prune/configmap-orphan.yaml b/argocd/fixtures/manifests/prune/configmap-orphan.yaml new file mode 100644 index 00000000..50e736c2 --- /dev/null +++ b/argocd/fixtures/manifests/prune/configmap-orphan.yaml @@ -0,0 +1,9 @@ +# Removed from git by the second seed commit. After the app is synced at the +# FIRST commit this exists in the cluster with no git counterpart, so a sync +# with --prune has something real to delete. +apiVersion: v1 +kind: ConfigMap +metadata: + name: prune-me +data: + role: "orphan" diff --git a/argocd/fixtures/manifests/schema-error/deployment.yaml b/argocd/fixtures/manifests/schema-error/deployment.yaml new file mode 100644 index 00000000..6369a96c --- /dev/null +++ b/argocd/fixtures/manifests/schema-error/deployment.yaml @@ -0,0 +1,22 @@ +# `reploicas` is a deliberate typo. The API server rejects it with a strict +# decoding error, which Argo CD's dry-run apply surfaces as SyncFailed. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: schema-error +spec: + reploicas: 1 + selector: + matchLabels: + app: schema-error + template: + metadata: + labels: + app: schema-error + spec: + containers: + - name: pause + image: registry.k8s.io/pause:3.9 + resources: + limits: {cpu: "10m", memory: "16Mi"} + requests: {cpu: "5m", memory: "8Mi"} diff --git a/argocd/fixtures/seed-sync-fixtures.sh b/argocd/fixtures/seed-sync-fixtures.sh new file mode 100755 index 00000000..87197108 --- /dev/null +++ b/argocd/fixtures/seed-sync-fixtures.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Seed three Argo CD apps for testing sync options, using the same mechanism as +# scripts/seed-history.sh: a local git repo served to the k3d cluster by the git +# daemon from `make argocd-git-daemon`. +# +# The prune fixtures work by making the manifest set shrink between two commits. +# The apps are synced at the FIRST commit (which contains an extra ConfigMap), +# while their targetRevision tracks `main` (which no longer has it). That leaves +# a live resource with no git counterpart — a genuine prune candidate. +set -euo pipefail + +FIXTURE_DIR=$(cd "$(dirname "$0")" && pwd) +# The git daemon serves the parent directory of the argonaut repo. When this +# script lives inside the checkout that is derivable; otherwise set the env var. +BASE_DIR=${GIT_DAEMON_BASE_PATH:-} +if [ -z "$BASE_DIR" ]; then + TOPLEVEL=$(git -C "$FIXTURE_DIR" rev-parse --show-toplevel 2>/dev/null || true) + [ -n "$TOPLEVEL" ] && BASE_DIR=$(dirname "$TOPLEVEL") +fi +if [ -z "$BASE_DIR" ] || [ ! -d "$BASE_DIR" ]; then + echo "Cannot locate the git daemon base path. Set GIT_DAEMON_BASE_PATH to the" >&2 + echo "directory the daemon serves (the parent of the argonaut checkout)." >&2 + exit 1 +fi + +# Argo CD reaches the host's git daemon by name only when k3d registered +# host.k3d.internal in CoreDNS. Older clusters have no such entry, so fall back +# to the docker network gateway, which is the host as seen from inside. +GIT_HOST=${GIT_HOST:-} +if [ -z "$GIT_HOST" ]; then + if kubectl -n kube-system get cm coredns -o jsonpath='{.data.NodeHosts}' 2>/dev/null | grep -q host.k3d.internal; then + GIT_HOST=host.k3d.internal + else + GIT_HOST=$(docker network inspect "k3d-${K3D_CLUSTER:-argocd-demo}" \ + --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}' 2>/dev/null) + fi +fi +if [ -z "$GIT_HOST" ]; then + echo "Cannot work out how the cluster reaches this host; set GIT_HOST." >&2 + exit 1 +fi +echo "Cluster will clone from git://$GIT_HOST/ ..." + +REPO_NAME=argonaut-sync-fixtures-repo +REPO_DIR="$BASE_DIR/$REPO_NAME" +GIT_DAEMON_PORT=${GIT_DAEMON_PORT:-9418} + +if ! argocd account get-user-info >/dev/null 2>&1; then + echo "Argo CD is not reachable — run 'make argocd-up' first." >&2 + exit 1 +fi +if ! bash -c "exec 3<>/dev/tcp/127.0.0.1/$GIT_DAEMON_PORT; exec 3<&-" 2>/dev/null; then + echo "No git daemon on :$GIT_DAEMON_PORT — run 'make argocd-git-daemon' first." >&2 + exit 1 +fi + +APPS=(prune-demo prune-confirm-demo schema-error-demo) + +# Fresh repo AND fresh apps every run: a previous run may already have pruned +# the orphan, and old apps would still reference commits this reset destroys. +for app in "${APPS[@]}"; do + if argocd app get "$app" >/dev/null 2>&1; then + echo "Deleting previous '$app' ..." + argocd app delete "$app" --yes >/dev/null + fi +done +for app in "${APPS[@]}"; do + for _ in $(seq 1 60); do + argocd app get "$app" >/dev/null 2>&1 || break + sleep 1 + done +done + +rm -rf "$REPO_DIR" +mkdir -p "$REPO_DIR" +cp -R "$FIXTURE_DIR/manifests/." "$REPO_DIR/" +cd "$REPO_DIR" +git init -q -b main +git config user.name "Argonaut Demo" +git config user.email "demo@argonaut.local" + +git add -A +git commit -q -m "feat: initial fixture manifests" +ORPHAN_REV=$(git rev-parse HEAD) + +git rm -q prune/configmap-orphan.yaml prune-confirm/configmap-orphan.yaml +git commit -q -m "chore: drop the orphan config maps" + +echo "Seeded $REPO_DIR (orphans present at $ORPHAN_REV, gone at HEAD)." + +sed "s|git://host.k3d.internal/|git://$GIT_HOST/|g" "$FIXTURE_DIR/apps-sync-options.yaml" \ + | kubectl apply -f - + +# Sync the prune fixtures at the commit that still has the orphan, so it lands +# in the cluster. The apps then track `main` and go OutOfSync with a prunable +# resource. schema-error-demo is left unsynced on purpose. +for app in prune-demo prune-confirm-demo; do + echo "Syncing $app @ $ORPHAN_REV ..." + argocd app sync "$app" --revision "$ORPHAN_REV" >/dev/null +done + +cat < Date: Wed, 19 Aug 2026 20:16:50 +0200 Subject: [PATCH 2/4] test: verify a resource-scoped sync stays scoped --- e2e/real_argocd_test.go | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/e2e/real_argocd_test.go b/e2e/real_argocd_test.go index 91ca3c11..f4739bc5 100644 --- a/e2e/real_argocd_test.go +++ b/e2e/real_argocd_test.go @@ -293,3 +293,40 @@ func init() { } } } + +func TestRealArgoCD_ResourceSyncSendsOnlyThatResource(t *testing.T) { + r := connectRealArgo(t) + const app = "prune-demo" + since := mark() + + tf := startAgainstRealArgo(t, r) + if !tf.WaitForPlain("NAME", realTimeout) { + t.Fatalf("the TUI never connected:\n%s", tf.Screen()) + } + openCommand(t, tf) + _ = tf.Send("resources " + app) + _ = tf.Enter() + if !tf.WaitForScreen("keep-me", realTimeout) { + t.Fatalf("resource tree never loaded:\n%s", tf.Screen()) + } + + // Select the one ConfigMap under the application root, then sync it. + _ = tf.Send("j") + _ = tf.Send(" ") + _ = tf.Send("s") + if !tf.WaitForScreen("Sync", 10*time.Second) { + t.Fatalf("resource sync modal never opened:\n%s", tf.Screen()) + } + _ = tf.Send("y") + + after := r.waitForOperationAfter(t, app, since) + + resources, _ := dig(after, "status", "operationState", "operation", "sync", "resources").([]any) + if len(resources) != 1 { + t.Fatalf("expected the sync scoped to one resource, got %v", + dig(after, "status", "operationState", "operation", "sync", "resources")) + } + if name, _ := resources[0].(map[string]any)["name"].(string); name != "keep-me" { + t.Errorf("expected the selected resource in the request, got %v", resources[0]) + } +} From cceea96e91e0e5fc8c8c286ceec094289ee108a2 Mon Sep 17 00:00:00 2001 From: darksworm <9987548+darksworm@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:09:42 +0200 Subject: [PATCH 3/4] test: harden real Argo CD harness and fixture reset --- argocd/fixtures/README.md | 28 ++++--- argocd/fixtures/seed-sync-fixtures.sh | 12 ++- argocd/fixtures/seed_sync_fixtures_test.py | 66 ++++++++++++++++ e2e/driver_unix_test.go | 6 +- e2e/real_argocd_client_test.go | 90 ++++++++++++++++++++++ e2e/real_argocd_test.go | 43 ++++++++--- 6 files changed, 225 insertions(+), 20 deletions(-) create mode 100644 argocd/fixtures/seed_sync_fixtures_test.py create mode 100644 e2e/real_argocd_client_test.go diff --git a/argocd/fixtures/README.md b/argocd/fixtures/README.md index 461e9eb5..f0ed51b5 100644 --- a/argocd/fixtures/README.md +++ b/argocd/fixtures/README.md @@ -1,27 +1,37 @@ -# Sync-option fixtures (draft) +# Sync-option fixtures Three small Argo CD Applications for exercising sync options against the local -k3d Argo CD. Nothing here is in the argonaut repo yet — these are drafts. +k3d Argo CD. ```bash make argocd-up make argocd-git-daemon -./seed-sync-fixtures.sh +./argocd/fixtures/seed-sync-fixtures.sh ``` `seed-sync-fixtures.sh` reuses `scripts/seed-history.sh`'s mechanism: it builds a throwaway git repo next to the argonaut checkout (`argonaut-sync-fixtures-repo`), which the `git daemon` from `make argocd-git-daemon` exports, so the cluster clones it as `git://host.k3d.internal/argonaut-sync-fixtures-repo` with no push -to any remote. Re-running the script rebuilds the repo and re-syncs all three -apps. The Applications carry no finalizer (matching `apps-hang.yaml`), so -deleting them leaves the ConfigMaps and namespaces behind; the next run adopts -them again, so the end state is right even though it is not a clean slate. +to any remote. Re-running the script deletes the old Applications with +`argocd app delete --yes`, which cascades to managed resources by default, +then rebuilds the repo and syncs the two prune apps. The schema-error app is +left unsynced. The script stops if an old Application remains after 60 seconds, +before rebuilding the repository or applying new Applications. -If these move into the repo, `argocd/fixtures/` or `scripts/fixtures/` both work -— the script derives the daemon base path from `git rev-parse --show-toplevel`. +The script derives the daemon base path from `git rev-parse --show-toplevel`. Outside a checkout, set `GIT_DAEMON_BASE_PATH`. +The real e2e harness accepts only loopback endpoints (`localhost`, `127.0.0.1`, +or `::1`) and honors the CLI context's `insecure` setting. Use the local context +created by `make argocd-login` for the demo's self-signed certificate. + +Run the seed script's isolated regression tests without a cluster: + +```bash +python3 -B -m unittest discover -s argocd/fixtures -p '*_test.py' +``` + ## The fixtures | App | Namespace | What it demonstrates | diff --git a/argocd/fixtures/seed-sync-fixtures.sh b/argocd/fixtures/seed-sync-fixtures.sh index 87197108..4b8b1d45 100755 --- a/argocd/fixtures/seed-sync-fixtures.sh +++ b/argocd/fixtures/seed-sync-fixtures.sh @@ -49,7 +49,13 @@ if ! argocd account get-user-info >/dev/null 2>&1; then echo "Argo CD is not reachable — run 'make argocd-up' first." >&2 exit 1 fi -if ! bash -c "exec 3<>/dev/tcp/127.0.0.1/$GIT_DAEMON_PORT; exec 3<&-" 2>/dev/null; then +if ! [[ "$GIT_DAEMON_PORT" =~ ^[0-9]{1,5}$ ]] || + ! (( 10#$GIT_DAEMON_PORT >= 1 && 10#$GIT_DAEMON_PORT <= 65535 )); then + echo "Invalid GIT_DAEMON_PORT: expected a port from 1 to 65535." >&2 + exit 1 +fi +GIT_DAEMON_PORT=$((10#$GIT_DAEMON_PORT)) +if ! bash -c 'exec 3<>"/dev/tcp/127.0.0.1/$1" || exit 1; exec 3<&-' _ "$GIT_DAEMON_PORT" 2>/dev/null; then echo "No git daemon on :$GIT_DAEMON_PORT — run 'make argocd-git-daemon' first." >&2 exit 1 fi @@ -69,6 +75,10 @@ for app in "${APPS[@]}"; do argocd app get "$app" >/dev/null 2>&1 || break sleep 1 done + if argocd app get "$app" >/dev/null 2>&1; then + echo "Timed out waiting for Application '$app' to be deleted; fixtures were not reset." >&2 + exit 1 + fi done rm -rf "$REPO_DIR" diff --git a/argocd/fixtures/seed_sync_fixtures_test.py b/argocd/fixtures/seed_sync_fixtures_test.py new file mode 100644 index 00000000..ca9049eb --- /dev/null +++ b/argocd/fixtures/seed_sync_fixtures_test.py @@ -0,0 +1,66 @@ +"""Hermetic seed-script regression tests: python3 -m unittest discover -s argocd/fixtures -p '*_test.py'.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).with_name('seed-sync-fixtures.sh').resolve() + + +class SeedFixturesTest(unittest.TestCase): + def run_seed(self, port='9418', stuck=False, probe_fails=False): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + bin_dir = root / 'bin' + bin_dir.mkdir() + repo = root / 'argonaut-sync-fixtures-repo' + repo.mkdir() + sentinel = repo / 'sentinel' + sentinel.touch() + stubs = { + 'argocd': '#!/bin/bash\necho "$*" >> "$CALLS"\nif [[ "$1 $2" == "app get" ]]; then [[ "$STUCK" == 1 ]]; else exit 0; fi\n', + 'bash': '#!/bin/sh\n[ "$PROBE_FAILS" != 1 ]\n', + 'sleep': '#!/bin/sh\nexit 0\n', + 'kubectl': '#!/bin/sh\necho "kubectl $*" >> "$CALLS"\ncat >/dev/null\n', + } + for name, contents in stubs.items(): + path = bin_dir / name + path.write_text(contents) + path.chmod(0o755) + calls = root / 'calls' + env = dict(os.environ, PATH=f'{bin_dir}:' + os.environ['PATH'], + GIT_DAEMON_BASE_PATH=tmp, GIT_HOST='127.0.0.1', + GIT_DAEMON_PORT=port, STUCK=str(int(stuck)), + PROBE_FAILS=str(int(probe_fails)), CALLS=str(calls)) + result = subprocess.run(['/bin/bash', str(SCRIPT)], env=env, capture_output=True, text=True, timeout=10) + return result, sentinel.exists(), calls.read_text() if calls.exists() else '' + + def test_invalid_ports_stop_before_reset(self): + for port in ['0', '65536', '99999999999999999999', '9418; echo injected', 'abc']: + with self.subTest(port=port): + result, preserved, calls = self.run_seed(port=port) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(preserved, result.stdout) + self.assertNotIn('app delete', calls) + + def test_deletion_timeout_preserves_repo_and_stops_sync(self): + result, preserved, calls = self.run_seed(stuck=True) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(preserved, result.stdout) + self.assertIn('prune-demo', result.stderr) + self.assertNotIn('app sync', calls) + self.assertNotIn('kubectl apply', calls) + + def test_disappeared_apps_allow_reset_and_sync(self): + result, preserved, calls = self.run_seed() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(preserved) + self.assertIn('app sync prune-demo', calls) + self.assertIn('app sync prune-confirm-demo', calls) + + def test_failed_probe_stops_before_reset(self): + result, preserved, _ = self.run_seed(probe_fails=True) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(preserved) + self.assertIn('No git daemon on :9418', result.stderr) diff --git a/e2e/driver_unix_test.go b/e2e/driver_unix_test.go index f8d44396..5f0f3770 100644 --- a/e2e/driver_unix_test.go +++ b/e2e/driver_unix_test.go @@ -746,11 +746,15 @@ func MockArgoServerExpiredToken() (*httptest.Server, error) { // WriteArgoConfigWithToken writes a CLI config using a specific token func WriteArgoConfigWithToken(path, baseURL, token string) error { + return writeArgoConfigWithTLS(path, baseURL, token, true) +} + +func writeArgoConfigWithTLS(path, baseURL, token string, insecure bool) error { var y bytes.Buffer y.WriteString("contexts:\n") y.WriteString(" - name: default\n server: " + baseURL + "\n user: default-user\n") y.WriteString("servers:\n") - y.WriteString(" - server: " + baseURL + "\n insecure: true\n") + fmt.Fprintf(&y, " - server: %s\n insecure: %t\n", baseURL, insecure) y.WriteString("users:\n") y.WriteString(" - name: default-user\n auth-token: " + token + "\n") y.WriteString("current-context: default\n") diff --git a/e2e/real_argocd_client_test.go b/e2e/real_argocd_client_test.go new file mode 100644 index 00000000..5c88497e --- /dev/null +++ b/e2e/real_argocd_client_test.go @@ -0,0 +1,90 @@ +//go:build e2e && unix + +package main + +import ( + "github.com/darksworm/argonaut/pkg/config" + "net/http" + "net/http/httptest" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/darksworm/argonaut/pkg/model" +) + +func TestRealArgoClientRejectsRemoteTargets(t *testing.T) { + for _, target := range []string{"https://argo.example.com", "http://argo.example.com", "https://localhost.example.com", "https://127.0.0.1@argo.example.com", "ftp://localhost", "://bad"} { + t.Run(target, func(t *testing.T) { + if _, err := newRealArgo(&model.Server{BaseURL: target, Insecure: true}); err == nil { + t.Fatalf("accepted non-local or invalid target %q", target) + } + }) + } +} + +func TestRealArgoClientHonorsTLSSetting(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) + defer srv.Close() + for _, insecure := range []bool{false, true} { + r, err := newRealArgo(&model.Server{BaseURL: srv.URL, Insecure: insecure}) + if err != nil { + t.Fatal(err) + } + resp, err := r.client.Get(srv.URL) + if resp != nil { + resp.Body.Close() + } + if insecure && err != nil { + t.Fatalf("explicit local insecure connection failed: %v", err) + } + if !insecure && err == nil { + t.Fatal("accepted an untrusted certificate despite insecure=false") + } + } +} + +func TestRealArgoClientDoesNotFollowRedirects(t *testing.T) { + var reached atomic.Bool + dst := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached.Store(true) })) + defer dst.Close() + src := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, dst.URL, http.StatusFound) })) + defer src.Close() + r, err := newRealArgo(&model.Server{BaseURL: src.URL}) + if err != nil { + t.Fatal(err) + } + resp, err := r.client.Get(src.URL) + if resp != nil { + resp.Body.Close() + } + if reached.Load() { + t.Fatal("followed a redirect outside the configured endpoint") + } +} + +func TestRealArgoClientLocalConfigPreservesTLS(t *testing.T) { + for _, target := range []string{"https://localhost:8080", "https://127.0.0.1:8080", "https://[::1]:8080"} { + for _, insecure := range []bool{false, true} { + r, err := newRealArgo(&model.Server{BaseURL: target, Token: "fixture-token", Insecure: insecure}) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "config") + if err := writeArgoConfigWithTLS(path, r.baseURL, r.token, r.insecure); err != nil { + t.Fatal(err) + } + cfg, err := config.ReadCLIConfigFromPath(path) + if err != nil { + t.Fatal(err) + } + server, err := cfg.ToServerConfig() + if err != nil { + t.Fatal(err) + } + if server.Insecure != insecure || server.Token != r.token || server.BaseURL != target { + t.Fatal("generated TUI config changed the endpoint, token, or TLS setting") + } + } + } +} diff --git a/e2e/real_argocd_test.go b/e2e/real_argocd_test.go index f4739bc5..5c8973bf 100644 --- a/e2e/real_argocd_test.go +++ b/e2e/real_argocd_test.go @@ -7,12 +7,15 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" + "net/url" "os" "testing" "time" "github.com/darksworm/argonaut/pkg/config" + "github.com/darksworm/argonaut/pkg/model" ) // The horizontal suite drives the real TUI against a real Argo CD and then @@ -33,9 +36,10 @@ const ( ) type realArgo struct { - baseURL string - token string - client *http.Client + baseURL string + token string + insecure bool + client *http.Client } // connectRealArgo reads the same CLI config the app reads, so the test @@ -58,16 +62,37 @@ func connectRealArgo(t *testing.T) *realArgo { t.Fatal("no auth token in the argocd CLI config — run 'make argocd-login'") } + r, err := newRealArgo(server) + if err != nil { + t.Fatal(err) + } + return r +} + +func newRealArgo(server *model.Server) (*realArgo, error) { + target, err := url.Parse(server.BaseURL) + if err != nil { + return nil, fmt.Errorf("invalid local Argo CD URL: %w", err) + } + ip := net.ParseIP(target.Hostname()) + if (target.Scheme != "http" && target.Scheme != "https") || target.User != nil || + (target.Hostname() != "localhost" && (ip == nil || !ip.IsLoopback())) { + return nil, fmt.Errorf("real Argo CD tests require a loopback HTTP(S) endpoint") + } + return &realArgo{ - baseURL: server.BaseURL, - token: server.Token, + baseURL: server.BaseURL, + insecure: server.Insecure, + token: server.Token, client: &http.Client{ Timeout: 10 * time.Second, + // A redirect must not escape the validated local endpoint. + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // local k3d, self-signed + TLSClientConfig: &tls.Config{InsecureSkipVerify: server.Insecure}, //nolint:gosec // explicit CLI setting, loopback only }, }, - } + }, nil } // app fetches an application straight from the Argo CD API. Assertions read @@ -173,7 +198,7 @@ func startAgainstRealArgo(t *testing.T, r *realArgo) *TUITestFramework { if err != nil { t.Fatalf("setup workspace: %v", err) } - if err := WriteArgoConfigWithToken(cfgPath, r.baseURL, r.token); err != nil { + if err := writeArgoConfigWithTLS(cfgPath, r.baseURL, r.token, r.insecure); err != nil { t.Fatalf("write config: %v", err) } if err := tf.StartAppArgs([]string{"-argocd-config=" + cfgPath}); err != nil { @@ -249,7 +274,7 @@ func TestRealArgoCD_DryRunSyncAppliesNothing(t *testing.T) { // The pane label is the one deliverable only the screen can confirm; // everything else here is asserted against the server. - if !tf.WaitForScreen("dry run", realTimeout) { + if !tf.WaitForScreen("Sync (dry run)", realTimeout) { t.Errorf("expected the sync status pane to mark the dry run:\n%s", tf.Screen()) } } From 0f46c5f2ca0620bf015738d83af21fbf2e6c4b65 Mon Sep 17 00:00:00 2001 From: darksworm <9987548+darksworm@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:36:04 +0200 Subject: [PATCH 4/4] test: reject stale operations from the current timestamp second --- e2e/real_argocd_operation_test.go | 56 +++++++++++++++++++++++++++++++ e2e/real_argocd_test.go | 26 ++++++++------ 2 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 e2e/real_argocd_operation_test.go diff --git a/e2e/real_argocd_operation_test.go b/e2e/real_argocd_operation_test.go new file mode 100644 index 00000000..dbc60f35 --- /dev/null +++ b/e2e/real_argocd_operation_test.go @@ -0,0 +1,56 @@ +//go:build e2e && unix + +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/darksworm/argonaut/pkg/model" +) + +func TestRealArgoMarkWaitsForNextSecond(t *testing.T) { + for _, offset := range []time.Duration{0, 100 * time.Millisecond, 900 * time.Millisecond} { + t.Run(offset.String(), func(t *testing.T) { + prior := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + clock := prior.Add(offset) + since := markWithClock(func() time.Time { return clock }, func(d time.Duration) { clock = clock.Add(d) }) + if !since.After(prior) { + t.Fatalf("mark %s still accepts an operation from the prior second", since) + } + if clock.Before(since) { + t.Fatalf("returned before the clock reached mark %s", since) + } + if !since.Equal(prior.Add(time.Second)) { + t.Fatalf("expected next second, got %s", since) + } + }) + } +} + +func TestRealArgoOperationIgnoresPreviousSecond(t *testing.T) { + prior := time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC) + clock := prior.Add(900 * time.Millisecond) + since := markWithClock(func() time.Time { return clock }, func(d time.Duration) { clock = clock.Add(d) }) + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started := prior + if requests.Add(1) > 1 { + started = prior.Add(time.Second) + } + fmt.Fprintf(w, `{"status":{"operationState":{"startedAt":%q,"phase":"Succeeded"}}}`, started.Format(time.RFC3339)) + })) + defer srv.Close() + client, err := newRealArgo(&model.Server{BaseURL: srv.URL}) + if err != nil { + t.Fatal(err) + } + app := client.waitForOperationAfter(t, "demo", since) + if got := digString(app, "status", "operationState", "startedAt"); got != prior.Add(time.Second).Format(time.RFC3339) { + t.Fatalf("accepted stale operation from %s", got) + } +} diff --git a/e2e/real_argocd_test.go b/e2e/real_argocd_test.go index 5c8973bf..70b06432 100644 --- a/e2e/real_argocd_test.go +++ b/e2e/real_argocd_test.go @@ -145,16 +145,22 @@ func digString(obj map[string]any, path ...string) string { return s } -// mark is the wall-clock instant a test began driving the TUI. Assertions wait -// for an operation that started after it, so a run can never pass on the -// operation a previous run — or the seed script — left behind. Comparing -// against the previously seen startedAt is not enough: Argo CD's app cache can -// serve a stale operationState for a second or two, which lets an older -// operation look new. -// Argo CD stamps startedAt at one-second resolution, so the mark is truncated -// and the comparison is inclusive — an operation started inside the same second -// as the mark is this test's, not a previous run's. -func mark() time.Time { return time.Now().UTC().Truncate(time.Second) } +// mark waits for the next whole second before the caller drives the TUI. +// Argo CD records startedAt at one-second resolution: rounding down would +// allow an old operation from the current second to satisfy the wait. +// The new boundary is inclusive so an operation started immediately after +// mark returns is accepted. This assumes the local cluster clock is aligned +// with the test host; the fixtures must not be driven concurrently. +func mark() time.Time { return markWithClock(time.Now, time.Sleep) } + +func markWithClock(now func() time.Time, sleep func(time.Duration)) time.Time { + next := now().UTC().Truncate(time.Second).Add(time.Second) + // This wait deliberately crosses the server timestamp's precision boundary. + for current := now(); current.Before(next); current = now() { + sleep(next.Sub(current)) + } + return next +} // waitForOperationAfter polls until an operation that started after `since` // reaches a terminal phase, and returns the app at that point.