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..f0ed51b5 --- /dev/null +++ b/argocd/fixtures/README.md @@ -0,0 +1,139 @@ +# Sync-option fixtures + +Three small Argo CD Applications for exercising sync options against the local +k3d Argo CD. + +```bash +make argocd-up +make argocd-git-daemon +./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 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. + +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 | +|---|---|---| +| `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..4b8b1d45 --- /dev/null +++ b/argocd/fixtures/seed-sync-fixtures.sh @@ -0,0 +1,128 @@ +#!/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 ! [[ "$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 + +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 + 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" +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 <> "$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_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 new file mode 100644 index 00000000..70b06432 --- /dev/null +++ b/e2e/real_argocd_test.go @@ -0,0 +1,363 @@ +//go:build e2e && unix + +package main + +import ( + "crypto/tls" + "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 +// asserts on the server's own state. The vertical suite proves argonaut sends +// what we think it sends; only this one proves Argo CD understood it. +// +// make argocd-up && make argocd-git-daemon +// ./argocd/fixtures/seed-sync-fixtures.sh +// make real-e2e +// +// Skipped unless ARGONAUT_REAL_ARGOCD=1, so it stays compiled — and therefore +// honest — without running in CI. + +const ( + realArgoEnv = "ARGONAUT_REAL_ARGOCD" + // Real syncs are not instant the way the mock is. + realTimeout = 30 * time.Second +) + +type realArgo struct { + baseURL string + token string + insecure bool + client *http.Client +} + +// connectRealArgo reads the same CLI config the app reads, so the test +// authenticates exactly the way a user does. +func connectRealArgo(t *testing.T) *realArgo { + t.Helper() + if os.Getenv(realArgoEnv) != "1" { + t.Skipf("horizontal e2e: set %s=1 with a local Argo CD running (see make argocd-up)", realArgoEnv) + } + + cfg, err := config.ReadCLIConfig() + if err != nil { + t.Fatalf("reading the argocd CLI config: %v (run 'make argocd-login')", err) + } + server, err := cfg.ToServerConfig() + if err != nil { + t.Fatalf("resolving the current argocd context: %v", err) + } + if server.Token == "" { + 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, + 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: server.Insecure}, //nolint:gosec // explicit CLI setting, loopback only + }, + }, + }, nil +} + +// app fetches an application straight from the Argo CD API. Assertions read +// this, never the screen, so a failure names the server state that was wrong. +func (r *realArgo) app(t *testing.T, name string) map[string]any { + t.Helper() + req, err := http.NewRequest(http.MethodGet, r.baseURL+"/api/v1/applications/"+name, nil) + if err != nil { + t.Fatalf("building the request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+r.token) + + resp, err := r.client.Do(req) + if err != nil { + t.Fatalf("querying Argo CD for %q: %v", name, err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("Argo CD returned %d for %q: %s", resp.StatusCode, name, body) + } + + var out map[string]any + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("decoding the application: %v", err) + } + return out +} + +// dig walks a decoded JSON object, returning nil at the first missing key +// rather than panicking, so a failure reports the path it wanted. +func dig(obj map[string]any, path ...string) any { + var current any = obj + for _, key := range path { + m, ok := current.(map[string]any) + if !ok { + return nil + } + current, ok = m[key] + if !ok { + return nil + } + } + return current +} + +func digString(obj map[string]any, path ...string) string { + s, _ := dig(obj, path...).(string) + return s +} + +// 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. +func (r *realArgo) waitForOperationAfter(t *testing.T, name string, since time.Time) map[string]any { + t.Helper() + deadline := time.Now().Add(realTimeout) + var lastPhase, lastStart string + for time.Now().Before(deadline) { + app := r.app(t, name) + started := digString(app, "status", "operationState", "startedAt") + phase := digString(app, "status", "operationState", "phase") + lastPhase, lastStart = phase, started + + startedAt, err := time.Parse(time.RFC3339, started) + if err == nil && !startedAt.Before(since) { + switch phase { + case "Succeeded", "Failed", "Error": + t.Logf("operation on %q started %s, phase %q, sync=%v", + name, started, phase, + dig(app, "status", "operationState", "operation", "sync")) + return app + } + } + time.Sleep(500 * time.Millisecond) + } + t.Fatalf("no operation on %q started after %s and finished within %s (latest started %q, phase %q)", + name, since.Format(time.RFC3339), realTimeout, lastStart, lastPhase) + return nil +} + +// startAgainstRealArgo boots the TUI pointed at the live server. The driver +// itself is server-agnostic; only the 2s request timeout it writes by default +// is too tight for a real one. +func startAgainstRealArgo(t *testing.T, r *realArgo) *TUITestFramework { + t.Helper() + tf := NewTUITest(t) + t.Cleanup(tf.Cleanup) + tf.requestTimeout = "15s" + + cfgPath, err := tf.SetupWorkspace() + if err != nil { + t.Fatalf("setup workspace: %v", err) + } + 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 { + t.Fatalf("start app: %v", err) + } + return tf +} + +// openCommand retries the command bar. Against a real server the first +// keystrokes can land while the initial application load still owns the +// screen, and a swallowed ":" is not a failure worth failing a test over. +func openCommand(t *testing.T, tf *TUITestFramework) { + t.Helper() + var err error + for attempt := 0; attempt < 5; attempt++ { + if err = tf.OpenCommand(); err == nil { + return + } + time.Sleep(time.Second) + } + t.Fatalf("command bar never opened: %v\n%s", err, tf.Screen()) +} + +// openSyncModalFor navigates to the named app and opens its sync confirmation. +func openSyncModalFor(t *testing.T, tf *TUITestFramework, app string) { + t.Helper() + // The TUI opens on the clusters view; apps are a level down. + if !tf.WaitForPlain("NAME", realTimeout) { + t.Fatalf("the TUI never connected:\n%s", tf.Screen()) + } + openCommand(t, tf) + _ = tf.Send("apps") + _ = tf.Enter() + + // Filter down to the one app: a real cluster has more than fit on screen. + if err := tf.OpenSearch(); err != nil { + t.Fatalf("open search: %v\n%s", err, tf.Screen()) + } + _ = tf.Send(app) + _ = tf.Enter() + if !tf.WaitForPlain(app, realTimeout) { + t.Fatalf("app %q never appeared in the TUI:\n%s", app, tf.Screen()) + } + openCommand(t, tf) + _ = tf.Send("sync " + app) + _ = tf.Enter() + if !tf.WaitForScreen("Sync", 10*time.Second) { + t.Fatalf("sync modal never opened:\n%s", tf.Screen()) + } +} + +func TestRealArgoCD_DryRunSyncAppliesNothing(t *testing.T) { + r := connectRealArgo(t) + const app = "schema-error-demo" + + before := r.app(t, app) + beforeSync := digString(before, "status", "sync", "status") + since := mark() + + tf := startAgainstRealArgo(t, r) + openSyncModalFor(t, tf, app) + _ = tf.Send("d") // dry run + _ = tf.Send("y") // confirm + + after := r.waitForOperationAfter(t, app, since) + + if dryRun, _ := dig(after, "status", "operationState", "operation", "sync", "dryRun").(bool); !dryRun { + t.Errorf("expected operation.sync.dryRun=true, got %v", dig(after, "status", "operationState", "operation", "sync")) + } + if got := digString(after, "status", "sync", "status"); got != beforeSync { + t.Errorf("a dry run changed the app's sync status: %q became %q", beforeSync, got) + } + + // The pane label is the one deliverable only the screen can confirm; + // everything else here is asserted against the server. + if !tf.WaitForScreen("Sync (dry run)", realTimeout) { + t.Errorf("expected the sync status pane to mark the dry run:\n%s", tf.Screen()) + } +} + +func TestRealArgoCD_ForceSyncKeepsTheHookStrategy(t *testing.T) { + r := connectRealArgo(t) + const app = "prune-demo" + since := mark() + + tf := startAgainstRealArgo(t, r) + openSyncModalFor(t, tf, app) + _ = tf.Send("f") // force + _ = tf.Send("y") // confirm the sync + if !tf.WaitForScreen("Force sync", 10*time.Second) { + t.Fatalf("force confirmation never appeared:\n%s", tf.Screen()) + } + _ = tf.Send("y") // confirm the force + + after := r.waitForOperationAfter(t, app, since) + + strategy, _ := dig(after, "status", "operationState", "operation", "sync", "syncStrategy").(map[string]any) + if _, applyOnly := strategy["apply"]; applyOnly { + t.Errorf("a forced sync used the apply strategy, which skips sync hooks: %v", strategy) + } + hook, ok := strategy["hook"].(map[string]any) + if !ok { + t.Fatalf("expected syncStrategy.hook, got %v", strategy) + } + if force, _ := hook["force"].(bool); !force { + t.Errorf("expected hook.force=true, got %v", hook) + } +} + +func init() { + // Surface the target so a failing run says which server it hit. + if os.Getenv(realArgoEnv) == "1" { + if cfg, err := config.ReadCLIConfig(); err == nil { + if server, err := cfg.ToServerConfig(); err == nil { + fmt.Printf("horizontal e2e target: %s\n", server.BaseURL) + } + } + } +} + +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]) + } +}