From 0d4b508b45606e91684f4d150941ed295c8b3d05 Mon Sep 17 00:00:00 2001 From: Spraha Singh Date: Fri, 9 Oct 2026 07:05:27 +0530 Subject: [PATCH 1/2] fix: harden GHCR deployment security and logging --- docs/ghcr-deployment.md | 23 ++- scripts/deploy_ghcr.sh | 154 +++++++++++---- tests/test_deploy_ghcr_security.py | 292 +++++++++++++++++++++++++++++ 3 files changed, 426 insertions(+), 43 deletions(-) create mode 100644 tests/test_deploy_ghcr_security.py diff --git a/docs/ghcr-deployment.md b/docs/ghcr-deployment.md index f4113d4..de039ea 100644 --- a/docs/ghcr-deployment.md +++ b/docs/ghcr-deployment.md @@ -39,13 +39,16 @@ deployment helper. ## First controlled deployment 1. Do not proceed while the root filesystem has less than **1536 MiB free**. - The latest read-only check showed about **500 MiB free**, so the helper will - stop before creating rollback tags or pulling an image. The first GHCR image - can require roughly the current 672 MB uncompressed application-image size, - plus pull/unpack overhead; the helper also requires at least 300 MiB to remain - after pulling. It does not prune images or volumes to create space. -2. Resolve the exposed local environment credentials noted in the deployment - preparation report before starting the workflow. + The EC2 EBS volume was expanded to **12 GiB** and the root filesystem was + grown online. A subsequent check showed about **4.37 GiB free** (the exact + amount changes as Docker images and logs grow). The helper still checks the + live free space and inodes before changing anything; it requires 1536 MiB + before a pull and at least 300 MiB after it. It does not prune images or + volumes to create space. +2. The previously exposed credential categories have been rotated and the old + credentials revoked. Keep the replacement values only in their protected + provider settings and production `.env`; never paste or print them in Actions + logs or deployment diagnostics. 3. On the `main` branch, run Actions → **Test, publish, and deploy CodeLens** → Run workflow → `deploy`. The image is published only after both test runs pass. Approve the `production` environment job only after reviewing the @@ -64,8 +67,10 @@ deployment helper. The short-lived GHCR token is transferred over the verified SSH connection, stored temporarily with mode `0600`, used through Docker's `--password-stdin`, -and removed. Docker credentials use a temporary `DOCKER_CONFIG` and are removed -when the helper exits. +and removed. Docker credentials use a temporary mode-`0700` `DOCKER_CONFIG` and +are removed when the helper exits, including failure and handled-signal paths. +Authentication, pull, task-inspection, and Compose errors are reported using +fixed sanitized messages; raw command output is not forwarded to Actions logs. ## Rollback diff --git a/scripts/deploy_ghcr.sh b/scripts/deploy_ghcr.sh index 6c3898c..7951817 100644 --- a/scripts/deploy_ghcr.sh +++ b/scripts/deploy_ghcr.sh @@ -8,6 +8,43 @@ STATE_DIR="${CODELENS_STATE_DIR:-/home/ubuntu/.local/state/codelens-ghcr}" MIN_FREE_MIB="${CODELENS_MIN_FREE_MIB:-1536}" POST_PULL_FREE_MIB="${CODELENS_POST_PULL_FREE_MIB:-300}" PUBLIC_HEALTH_URL="${CODELENS_PUBLIC_HEALTH_URL:-https://13-51-158-45.sslip.io/health}" +AUTH_TMP_ROOT="${TMPDIR:-/tmp}" +AUTH_TOKEN_FILE="" +AUTH_DOCKER_CONFIG="" + +cleanup_auth() { + local cleanup_failed=0 token_name exit_status=$? + + if [[ -n "${AUTH_DOCKER_CONFIG:-}" ]]; then + case "$AUTH_DOCKER_CONFIG" in + "$AUTH_TMP_ROOT"/codelens-ghcr-auth.*) + rm -rf -- "$AUTH_DOCKER_CONFIG" >/dev/null 2>&1 || cleanup_failed=1 + ;; + *) cleanup_failed=1 ;; + esac + fi + + if [[ -n "${AUTH_TOKEN_FILE:-}" ]]; then + token_name="${AUTH_TOKEN_FILE##*/}" + if [[ "$AUTH_TOKEN_FILE" == "/tmp/$token_name" && "$token_name" =~ ^codelens-ghcr-token-[A-Za-z0-9._-]+$ ]]; then + rm -f -- "$AUTH_TOKEN_FILE" >/dev/null 2>&1 || cleanup_failed=1 + else + cleanup_failed=1 + fi + fi + + if (( cleanup_failed )); then + printf 'WARNING: temporary authentication cleanup was incomplete.\n' >&2 + (( exit_status != 0 )) || exit_status=1 + fi + trap - EXIT + exit "$exit_status" +} + +trap cleanup_auth EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM usage() { cat <<'USAGE' @@ -53,12 +90,16 @@ check_preconditions() { [[ -f "$COMPOSE_DIR/docker-compose.yml" && -f "$COMPOSE_DIR/docker-compose.prod.yml" ]] || { echo "STOP: expected Compose files are missing." >&2; return 1; } - compose_version="$(docker compose version --short | sed 's/^v//')" - if ! python3 -c 'import re, sys; v=tuple(int(x) for x in re.match(r"^(\d+)\.(\d+)\.(\d+)", sys.argv[1]).groups()); raise SystemExit(0 if v >= (2, 24, 4) else 1)' "$compose_version"; then + compose_version="$(docker compose version --short 2>/dev/null)" || { + echo "STOP: unable to verify Docker Compose version." >&2; return 1; + } + if ! python3 -c 'import re, sys; m=re.match(r"^v?(\d+)\.(\d+)\.(\d+)", sys.argv[1]); raise SystemExit(0 if m and tuple(map(int, m.groups())) >= (2, 24, 4) else 1)' "$compose_version" >/dev/null 2>&1; then echo "STOP: Docker Compose 2.24.4 or newer is required for build reset semantics." >&2 return 1 fi - services="$(compose_base "$1" config --services 2>/dev/null | sort | tr '\n' ' ')" + services="$(compose_base "$1" config --services 2>/dev/null | sort | tr '\n' ' ')" || { + echo "STOP: unable to validate the Compose services." >&2; return 1; + } for service in web worker flower caddy; do [[ " $services " == *" $service "* ]] || { echo "STOP: expected Compose service '$service' is absent." >&2; return 1; @@ -66,7 +107,9 @@ check_preconditions() { done for service in web worker flower; do local cid - cid="$(compose_base "$1" ps -q "$service")" + cid="$(compose_base "$1" ps -q "$service" 2>/dev/null)" || { + echo "STOP: unable to verify a running application service." >&2; return 1; + } [[ -n "$cid" ]] || { echo "STOP: running $service container was not found." >&2; return 1; } done } @@ -85,7 +128,7 @@ acquire_deployment_lock() { } assert_celery_quiescent() { - local output + local output active reserved scheduled unacked unacked_index if ! output="$(timeout 25s docker exec codelens-worker-1 python -c ' from app.core.celery_app import celery_app connection = celery_app.connection_for_read() @@ -103,13 +146,25 @@ for method in ("active", "reserved", "scheduled"): raise SystemExit("worker inspection unavailable: " + method) counts[method] = sum(len(items) for items in response.values()) print("active={active} reserved={reserved} scheduled={scheduled} unacked={unacked} unacked_index={unacked_index}".format(**counts, unacked=unacked, unacked_index=unacked_index)) -if any(counts.values()) or unacked or unacked_index: - raise SystemExit(3) -' 2>&1)"; then - printf 'STOP: could not verify Celery task state safely: %s\n' "$output" >&2 +' 2>/dev/null)"; then + echo "STOP: Celery task inspection failed; deployment aborted." >&2 + return 1 + fi + if [[ ! "$output" =~ ^active=([0-9]+)[[:space:]]reserved=([0-9]+)[[:space:]]scheduled=([0-9]+)[[:space:]]unacked=([0-9]+)[[:space:]]unacked_index=([0-9]+)$ ]]; then + echo "STOP: Celery task inspection returned unexpected output; deployment aborted." >&2 + return 1 + fi + active="${BASH_REMATCH[1]}" + reserved="${BASH_REMATCH[2]}" + scheduled="${BASH_REMATCH[3]}" + unacked="${BASH_REMATCH[4]}" + unacked_index="${BASH_REMATCH[5]}" + if (( active || reserved || scheduled || unacked || unacked_index )); then + printf 'STOP: Celery is not quiescent (active=%s reserved=%s scheduled=%s unacked=%s unacked_index=%s).\n' \ + "$active" "$reserved" "$scheduled" "$unacked" "$unacked_index" >&2 return 1 fi - printf 'Celery is quiescent: %s\n' "$output" + printf 'Celery task inspection: PASS (active=0 reserved=0 scheduled=0 unacked=0 unacked_index=0).\n' } verify_running_release() { @@ -122,7 +177,7 @@ verify_running_release() { if [[ "$status" == healthy ]] && [[ "$flower_status" =~ ^(200|301|302|401|403)$ ]]; then if timeout 15s docker exec codelens-worker-1 python -c \ 'from app.core.celery_app import celery_app; r=celery_app.control.ping(timeout=5); raise SystemExit(0 if r else 1)' >/dev/null 2>&1; then - if [[ -n "$PUBLIC_HEALTH_URL" ]] && curl --fail --silent --show-error --max-time 10 "$PUBLIC_HEALTH_URL" >/dev/null; then + if [[ -n "$PUBLIC_HEALTH_URL" ]] && curl --fail --silent --max-time 10 "$PUBLIC_HEALTH_URL" >/dev/null 2>&1; then return 0 fi # Public HTTPS can fail transiently while Caddy is serving a new web container. @@ -162,7 +217,7 @@ rollback_state() { local state_file="$1" override_file="$2" [[ -f "$state_file" ]] || { echo "STOP: rollback state file is missing." >&2; return 1; } write_rollback_override "$state_file" "$override_file" - if ! compose_base "$override_file" up -d --no-build --pull never --no-deps web worker flower; then + if ! compose_base "$override_file" up -d --no-build --pull never --no-deps web worker flower >/dev/null 2>&1; then echo "Rollback Compose operation failed; stop for manual inspection." >&2 return 1 fi @@ -174,6 +229,11 @@ deploy() { local run_id state_file temp_config web_id worker_id flower_id local web_tag worker_tag flower_tag revision platform free_before free_after + if [[ "$token_file" =~ ^/tmp/codelens-ghcr-token-[A-Za-z0-9._-]+$ ]]; then + AUTH_TOKEN_FILE="$token_file" + else + echo "STOP: invalid short-lived GHCR token file path." >&2; return 1 + fi [[ "$image_ref" =~ ^ghcr\.io/[a-z0-9][a-z0-9._-]*/codelens@sha256:[a-f0-9]{64}$ ]] || { echo "STOP: image must be a lowercase GHCR repository reference pinned by sha256 digest." >&2; return 1; } @@ -195,15 +255,27 @@ deploy() { [[ "$run_id" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "STOP: invalid deployment id." >&2; return 1; } state_file="$STATE_DIR/${run_id}.state" [[ ! -e "$state_file" ]] || { echo "STOP: deployment state already exists." >&2; return 1; } - web_id="$(docker inspect --format '{{.Image}}' "$(compose_base "$override_file" ps -q web)")" - worker_id="$(docker inspect --format '{{.Image}}' "$(compose_base "$override_file" ps -q worker)")" - flower_id="$(docker inspect --format '{{.Image}}' "$(compose_base "$override_file" ps -q flower)")" + web_id="$(docker inspect --format '{{.Image}}' "$(compose_base "$override_file" ps -q web 2>/dev/null)" 2>/dev/null)" || { + echo "STOP: unable to record the running web image for rollback." >&2; return 1; + } + worker_id="$(docker inspect --format '{{.Image}}' "$(compose_base "$override_file" ps -q worker 2>/dev/null)" 2>/dev/null)" || { + echo "STOP: unable to record the running worker image for rollback." >&2; return 1; + } + flower_id="$(docker inspect --format '{{.Image}}' "$(compose_base "$override_file" ps -q flower 2>/dev/null)" 2>/dev/null)" || { + echo "STOP: unable to record the running Flower image for rollback." >&2; return 1; + } web_tag="codelens-web:rollback-${run_id}" worker_tag="codelens-worker:rollback-${run_id}" flower_tag="codelens-flower:rollback-${run_id}" - docker image tag "$web_id" "$web_tag" - docker image tag "$worker_id" "$worker_tag" - docker image tag "$flower_id" "$flower_tag" + if ! docker image tag "$web_id" "$web_tag" >/dev/null 2>&1; then + echo "STOP: unable to preserve the web rollback image." >&2; return 1; + fi + if ! docker image tag "$worker_id" "$worker_tag" >/dev/null 2>&1; then + echo "STOP: unable to preserve the worker rollback image." >&2; return 1; + fi + if ! docker image tag "$flower_id" "$flower_tag" >/dev/null 2>&1; then + echo "STOP: unable to preserve the Flower rollback image." >&2; return 1; + fi cat > "$state_file" </dev/null)" || { + echo "STOP: unable to create temporary Docker authentication storage." >&2; return 1; } - trap cleanup_auth EXIT - docker login ghcr.io --username "$ghcr_username" --password-stdin < "$token_file" >/dev/null - rm -f -- "$token_file" - docker pull "$image_ref" + AUTH_DOCKER_CONFIG="$temp_config" + chmod 700 "$temp_config" 2>/dev/null || { + echo "STOP: unable to secure temporary Docker authentication storage." >&2; return 1; + } + export DOCKER_CONFIG="$temp_config" + if ! docker login ghcr.io --username "$ghcr_username" --password-stdin < "$token_file" >/dev/null 2>&1; then + echo "STOP: GHCR authentication failed." >&2; return 1 + fi + if ! rm -f -- "$token_file" >/dev/null 2>&1; then + echo "STOP: unable to remove the temporary GHCR token file." >&2; return 1 + fi + AUTH_TOKEN_FILE="" + if ! docker pull "$image_ref" >/dev/null 2>&1; then + echo "STOP: unable to pull the pinned GHCR image." >&2; return 1 + fi free_after="$(free_kib)" if (( free_after < POST_PULL_FREE_MIB * 1024 )); then echo "STOP: image pulled but free space fell below the post-pull safety floor; services were not changed." >&2 return 1 fi - revision="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image_ref")" - platform="$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$image_ref")" + revision="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image_ref" 2>/dev/null)" || { + echo "STOP: unable to verify the published image revision." >&2; return 1; + } + platform="$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$image_ref" 2>/dev/null)" || { + echo "STOP: unable to verify the published image platform." >&2; return 1; + } [[ "$revision" == "$expected_sha" ]] || { echo "STOP: image revision label does not match the requested commit." >&2; return 1; } - [[ "$platform" == linux/amd64 ]] || { echo "STOP: image platform $platform does not match EC2 linux/amd64." >&2; return 1; } - compose_base "$override_file" config --quiet + [[ "$platform" == linux/amd64 ]] || { echo "STOP: image platform does not match EC2 linux/amd64." >&2; return 1; } + if ! compose_base "$override_file" config --quiet >/dev/null 2>&1; then + echo "STOP: the deployment Compose configuration is invalid." >&2; return 1; + fi - if ! compose_base "$override_file" up -d --no-build --pull never --no-deps web worker flower; then + if ! compose_base "$override_file" up -d --no-build --pull never --no-deps web worker flower >/dev/null 2>&1; then echo "Deployment update failed; restoring the recorded image IDs." >&2 rollback_state "$state_file" "$override_file" || true return 1 @@ -249,7 +335,7 @@ STATE return 1 fi ln -sfn "$state_file" "$STATE_DIR/latest" - printf 'Deployment %s is healthy. Image digest: %s\n' "$run_id" "${image_ref##*@}" + printf 'Deployment: PASS. Image digest: %s\n' "${image_ref##*@}" printf 'Free space: before=%s MiB after=%s MiB\n' "$((free_before / 1024))" "$((free_after / 1024))" } @@ -264,7 +350,7 @@ rollback() { state_file="$STATE_DIR/${run_id}.state" fi rollback_state "$state_file" "$override_file" - printf 'Rollback to saved image IDs from %s is healthy.\n' "$state_file" + printf 'Rollback: PASS. Saved image IDs are healthy.\n' } main() { diff --git a/tests/test_deploy_ghcr_security.py b/tests/test_deploy_ghcr_security.py new file mode 100644 index 0000000..6d3bcc8 --- /dev/null +++ b/tests/test_deploy_ghcr_security.py @@ -0,0 +1,292 @@ +"""Secret-safe failure handling tests for the production deployment helper.""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / "scripts" / "deploy_ghcr.sh" +SECRET = "canary-secret-do-not-log" + + +DOCKER_MOCK = r'''#!/usr/bin/env bash +set -u +mode="${MOCK_MODE:-celery_failure}" +if [[ -n "${MOCK_CALLS:-}" ]]; then + case "$1:$2:${*:3}" in + compose:*up*) printf 'compose_up\\n' >>"$MOCK_CALLS" ;; + pull:*) printf 'docker_pull\\n' >>"$MOCK_CALLS" ;; + image:tag:*) printf 'image_tag\\n' >>"$MOCK_CALLS" ;; + exec:*) printf 'docker_exec\\n' >>"$MOCK_CALLS" ;; + *) printf 'other\\n' >>"$MOCK_CALLS" ;; + esac +fi +if [[ "$1" == compose ]]; then + args=" $* " + if [[ "$args" == *" version --short "* ]]; then + printf '5.5.1\n' + elif [[ "$args" == *" config --services "* ]]; then + printf 'web\nworker\nflower\ncaddy\n' + elif [[ "$args" == *" ps -q "* ]]; then + case "$args" in + *" ps -q web "*) printf 'web-container\n' ;; + *" ps -q worker "*) printf 'worker-container\n' ;; + *" ps -q flower "*) printf 'flower-container\n' ;; + *) exit 1 ;; + esac + elif [[ "$args" == *" up "* ]]; then + printf '%s\n' "$MOCK_SECRET" >&2 + exit 1 + fi + exit 0 +fi +if [[ "$1" == exec ]]; then + case "$mode" in + celery_failure) + printf '%s\n' "$MOCK_SECRET" + printf '%s\n' "$MOCK_SECRET" >&2 + exit 1 + ;; + celery_malformed) + printf 'inspection=%s\n' "$MOCK_SECRET" + exit 0 + ;; + celery_busy) + printf '%s\n' "${MOCK_COUNTS:-active=1 reserved=0 scheduled=0 unacked=0 unacked_index=0}" + exit 0 + ;; + celery_idle) + printf 'active=0 reserved=0 scheduled=0 unacked=0 unacked_index=0\n' + exit 0 + ;; + login_failure) + printf 'active=0 reserved=0 scheduled=0 unacked=0 unacked_index=0\n' + exit 0 + ;; + pull_failure|compose_failure) + printf 'active=0 reserved=0 scheduled=0 unacked=0 unacked_index=0\n' + exit 0 + ;; + esac +fi +if [[ "$1" == image && "$2" == tag ]]; then + exit 0 +fi +if [[ "$1" == image && "$2" == inspect ]]; then + case "$*" in + *'org.opencontainers.image.revision'*) printf '%s\n' "$EXPECTED_SHA" ;; + *'{{.Os}}/{{.Architecture}}'*) printf 'linux/amd64\n' ;; + *) printf 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' ;; + esac + exit 0 +fi +if [[ "$1" == login ]]; then + cat >/dev/null + printf '%s\n' "$MOCK_SECRET" >&2 + printf '%s\n' "$MOCK_SECRET" + printf '%s' "$MOCK_SECRET" >"$DOCKER_CONFIG/config.json" + if [[ "$mode" == login_failure ]]; then + exit 1 + fi + exit 0 +fi +if [[ "$1" == pull ]]; then + printf '%s\n' "$MOCK_SECRET" >&2 + printf '%s\n' "$MOCK_SECRET" + [[ "$mode" != pull_failure ]] && exit 0 + exit 1 +fi +if [[ "$1" == inspect ]]; then + case "$*" in + *'{{.Image}}'*) printf 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n' ;; + *'{{.State.Health.Status}}'*) printf 'healthy\n' ;; + esac + exit 0 +fi +printf '%s\n' "$MOCK_SECRET" >&2 +exit 1 +''' + + +@pytest.fixture +def deployment_sandbox(tmp_path: Path) -> tuple[dict[str, str], Path, Path]: + mock_bin = tmp_path / "bin" + mock_bin.mkdir() + docker = mock_bin / "docker" + docker.write_text(DOCKER_MOCK) + docker.chmod(0o700) + flock = mock_bin / "flock" + flock.write_text("#!/usr/bin/env bash\nexit 0\n") + flock.chmod(0o700) + timeout = mock_bin / "timeout" + timeout.write_text("#!/usr/bin/env bash\nshift\nexec \"$@\"\n") + timeout.chmod(0o700) + + compose_dir = tmp_path / "compose" + compose_dir.mkdir() + (compose_dir / ".env").write_text("test-only-placeholder\n") + (compose_dir / "codelens-spraha.2026-08-18.private-key.pem").write_text("test-only-placeholder\n") + (compose_dir / "docker-compose.yml").write_text("services: {}\n") + (compose_dir / "docker-compose.prod.yml").write_text("services: {}\n") + override = tmp_path / "override.yml" + override.write_text("services: {}\n") + + temp_root = tmp_path / "temporary" + temp_root.mkdir() + state_dir = tmp_path / "state" + env = { + "PATH": f"{mock_bin}:/usr/local/bin:/usr/bin:/bin", + "HOME": str(tmp_path), + "TMPDIR": str(temp_root), + "CODELENS_COMPOSE_DIR": str(compose_dir), + "CODELENS_STATE_DIR": str(state_dir), + "CODELENS_MIN_FREE_MIB": "1", + "CODELENS_POST_PULL_FREE_MIB": "1", + "CODELENS_PUBLIC_HEALTH_URL": "", + "CODELENS_DEPLOYMENT_ID": "test-deployment", + "EXPECTED_SHA": "1" * 40, + "MOCK_SECRET": SECRET, + "MOCK_MODE": "celery_failure", + "MOCK_CALLS": str(tmp_path / "mock-calls.txt"), + } + return env, override, temp_root + + +def run_deploy( + env: dict[str, str], override: Path, *, mode: str, token_path: Path +) -> subprocess.CompletedProcess[str]: + token_path.write_text("test-only-token\n") + token_path.chmod(0o600) + child_env = {**env, "MOCK_MODE": mode} + return subprocess.run( + [ + "bash", + str(HELPER), + "deploy", + "ghcr.io/example/codelens@sha256:" + "a" * 64, + "1" * 40, + "test-user", + str(token_path), + str(override), + ], + env=child_env, + text=True, + capture_output=True, + timeout=10, + check=False, + ) + + +def assert_secret_not_logged(result: subprocess.CompletedProcess[str]) -> None: + assert SECRET not in result.stdout + assert SECRET not in result.stderr + + +def test_celery_command_failure_redacts_stdout_and_stderr_and_fails_closed( + deployment_sandbox: tuple[dict[str, str], Path, Path], tmp_path: Path +) -> None: + env, override, _ = deployment_sandbox + token = Path("/tmp") / f"codelens-ghcr-token-test-{os.getpid()}" + try: + result = run_deploy(env, override, mode="celery_failure", token_path=token) + assert result.returncode != 0 + assert_secret_not_logged(result) + assert "Celery task inspection failed; deployment aborted." in result.stderr + assert "docker compose" not in result.stderr + calls = Path(env["MOCK_CALLS"]).read_text() + assert "docker_pull" not in calls + assert "image_tag" not in calls + assert "compose_up" not in calls + assert not token.exists() + finally: + token.unlink(missing_ok=True) + + +def test_unexpected_celery_output_is_rejected_without_echoing_it( + deployment_sandbox: tuple[dict[str, str], Path, Path] +) -> None: + env, override, _ = deployment_sandbox + token = Path("/tmp") / f"codelens-ghcr-token-test-{os.getpid()}-malformed" + try: + result = run_deploy(env, override, mode="celery_malformed", token_path=token) + assert result.returncode != 0 + assert_secret_not_logged(result) + assert "unexpected output" in result.stderr + assert not token.exists() + finally: + token.unlink(missing_ok=True) + + +@pytest.mark.parametrize( + "counts", + [ + "active=1 reserved=0 scheduled=0 unacked=0 unacked_index=0", + "active=0 reserved=1 scheduled=0 unacked=0 unacked_index=0", + "active=0 reserved=0 scheduled=1 unacked=0 unacked_index=0", + "active=0 reserved=0 scheduled=0 unacked=1 unacked_index=0", + "active=0 reserved=0 scheduled=0 unacked=0 unacked_index=1", + ], +) +def test_busy_celery_state_reports_only_allowlisted_counts( + deployment_sandbox: tuple[dict[str, str], Path, Path], counts: str +) -> None: + env, override, _ = deployment_sandbox + env["MOCK_COUNTS"] = counts + token = Path("/tmp") / f"codelens-ghcr-token-test-{os.getpid()}-busy" + try: + result = run_deploy(env, override, mode="celery_busy", token_path=token) + assert result.returncode != 0 + assert_secret_not_logged(result) + assert counts in result.stderr + assert "image" not in result.stderr + assert not token.exists() + finally: + token.unlink(missing_ok=True) + + +def test_ghcr_login_failure_redacts_output_and_removes_temporary_credentials( + deployment_sandbox: tuple[dict[str, str], Path, Path] +) -> None: + env, override, temp_root = deployment_sandbox + token = Path("/tmp") / f"codelens-ghcr-token-test-{os.getpid()}-login" + try: + result = run_deploy(env, override, mode="login_failure", token_path=token) + assert result.returncode != 0 + assert_secret_not_logged(result) + assert "GHCR authentication failed." in result.stderr + assert not token.exists() + assert list(temp_root.glob("codelens-ghcr-auth.*")) == [] + for config_file in temp_root.glob("**/config.json"): + assert SECRET not in config_file.read_text() + assert not any("STOP" in line and SECRET in line for line in result.stderr.splitlines()) + finally: + token.unlink(missing_ok=True) + + +@pytest.mark.parametrize( + ("mode", "expected_message"), + [ + ("pull_failure", "unable to pull the pinned GHCR image."), + ("compose_failure", "Deployment update failed; restoring the recorded image IDs."), + ], +) +def test_later_deployment_failures_redact_output_and_clean_auth_files( + deployment_sandbox: tuple[dict[str, str], Path, Path], mode: str, expected_message: str +) -> None: + env, override, temp_root = deployment_sandbox + token = Path("/tmp") / f"codelens-ghcr-token-test-{os.getpid()}-{mode}" + try: + result = run_deploy(env, override, mode=mode, token_path=token) + assert result.returncode != 0 + assert_secret_not_logged(result) + assert expected_message in result.stderr + assert not token.exists() + assert list(temp_root.glob("codelens-ghcr-auth.*")) == [] + finally: + token.unlink(missing_ok=True) From 4a13aecddcaa5afc800c4b480cdda771a9231309 Mon Sep 17 00:00:00 2001 From: Spraha Singh Date: Fri, 9 Oct 2026 07:09:45 +0530 Subject: [PATCH 2/2] fix: mount deployment helper in image tests --- .github/workflows/production.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 37fbdae..f3201e2 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -82,6 +82,7 @@ jobs: run: >- docker run --rm --network none --volume "$GITHUB_WORKSPACE/tests:/tmp/codelens-tests:ro" + --volume "$GITHUB_WORKSPACE/scripts:/tmp/scripts:ro" --volume "$GITHUB_WORKSPACE/pytest.ini:/tmp/pytest.ini:ro" --env DEBUG=false --env DATABASE_URL=postgresql+asyncpg://test:test@127.0.0.1/test @@ -132,6 +133,7 @@ jobs: run: >- docker run --rm --network none --volume "$GITHUB_WORKSPACE/tests:/tmp/codelens-tests:ro" + --volume "$GITHUB_WORKSPACE/scripts:/tmp/scripts:ro" --volume "$GITHUB_WORKSPACE/pytest.ini:/tmp/pytest.ini:ro" --env DEBUG=false --env DATABASE_URL=postgresql+asyncpg://test:test@127.0.0.1/test