diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 00000000..d5d3c091 --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,8 @@ +{ + "name": "Codec Carver", + "install": "bash .cursor/install.sh", + "start": "bash .cursor/start.sh", + "ports": [ + { "name": "web", "port": 8000 } + ] +} diff --git a/.cursor/install.sh b/.cursor/install.sh new file mode 100755 index 00000000..e8fa5864 --- /dev/null +++ b/.cursor/install.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Idempotent Cloud Agent setup for codec-carver. +set -euo pipefail + +# Pin to the repository root so pip/cargo always act on the checked-out tree, +# regardless of the caller's working directory. +cd "$(dirname "$0")/.." + +# ffmpeg AND ffprobe are required at runtime for probing and conversion. The +# default Cloud Agent base image already ships them; install only if either is +# missing. +if ! command -v ffmpeg >/dev/null 2>&1 || ! command -v ffprobe >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y --no-install-recommends ffmpeg +fi + +# The pinned toolchain (rust-toolchain.toml) uses profile = minimal, which omits +# rustfmt. Add it fail-closed: `cargo fmt --check` is a CI gate, so a missing +# formatter must break setup rather than silently ship format drift. +# `cargo build` below auto-installs the pinned toolchain first if needed. +cargo build --release --manifest-path rust-core/Cargo.toml +rustup component add rustfmt + +# Python dependencies follow CI's supply-chain contract: every runtime and +# dev/test dependency comes from the hash-locked sets, never resolved from the +# index. The package itself is then installed editable with --no-deps so no +# unpinned dependency is ever pulled. +python3 -m pip install --user --disable-pip-version-check --require-hashes -r requirements-lock.txt +python3 -m pip install --user --disable-pip-version-check --require-hashes -r fuzz/requirements-dev.txt +# Build isolation is left on (unlike CI's --no-index --no-build-isolation): this +# base image ships packaging 24.0, but setuptools 83 needs packaging>=24.2, so +# an isolated build environment supplies a compatible, version-pinned backend +# (from pyproject's build-system.requires) without touching runtime deps. +python3 -m pip install --user --disable-pip-version-check --no-deps -e . + +# Expose pip --user console scripts (codec-carver, codec-carver-library, ...) in +# non-login shells too. Login shells already pick up ~/.local/bin via ~/.profile. +if ! grep -q '.local/bin' "$HOME/.bashrc" 2>/dev/null; then + printf '\nif [ -d "$HOME/.local/bin" ]; then PATH="$HOME/.local/bin:$PATH"; fi\n' >> "$HOME/.bashrc" +fi diff --git a/.cursor/start.sh b/.cursor/start.sh new file mode 100755 index 00000000..b0f00280 --- /dev/null +++ b/.cursor/start.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Per-boot startup for codec-carver: +# 1. ensure the Rust backend binary exists after a fresh checkout, and +# 2. bring up the FastAPI SaaS web service and BLOCK until it is ready. +# This is ready-or-fail: a worker that never binds or dies during startup makes +# `start` exit non-zero (a failed boot) instead of leaving the published :8000 +# port pointing at nothing. +set -euo pipefail +cd "$(dirname "$0")/.." + +# 1) Rust backend binary used by the audio-library CLI (a fresh checkout wipes +# rust-core/target). +if [ ! -x rust-core/target/release/codec-carver-core ]; then + cargo build --release --manifest-path rust-core/Cargo.toml +fi + +# 2) FastAPI SaaS web service on :8000. Idempotent: if it is already serving, +# there is nothing to do. Every probe is time-bounded so a port that accepts +# TCP but never answers HTTP cannot hang startup. +readiness_url="http://127.0.0.1:8000/health" +probe() { curl -fsS --connect-timeout 3 --max-time 5 -o /dev/null "$readiness_url" 2>/dev/null; } +if probe; then + exit 0 +fi + +# `python3 -m uvicorn` is PATH-independent; logs go to a stable path. +nohup python3 -m uvicorn saas_web:app --host 0.0.0.0 --port 8000 \ + > /tmp/codec-carver-web.log 2>&1 & +web_pid=$! + +# Wait for readiness; fail fast if the worker exits or never becomes ready. +for _ in $(seq 1 60); do + if ! kill -0 "$web_pid" 2>/dev/null; then + echo "codec-carver web worker (pid $web_pid) exited during startup" >&2 + cat /tmp/codec-carver-web.log >&2 || true + exit 1 + fi + if probe; then + exit 0 + fi + sleep 1 +done + +echo "codec-carver web service did not become ready on $readiness_url within 60s" >&2 +cat /tmp/codec-carver-web.log >&2 || true +kill "$web_pid" 2>/dev/null || true +exit 1 diff --git a/AGENTS.md b/AGENTS.md index 090d7d2d..10e6b82b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,3 +92,26 @@ protection, `require_code_owner_review` in rulesets) are disabled across the Con org: there is a single maintainer (solo developer), so a code-owner approval gate can never be satisfied. This is ON HOLD until the org has multiple maintainers — do NOT re-enable these settings or add CODEOWNERS-based merge gates before then. + +## Cloud Agent environment (`.cursor/`) + +`.cursor/environment.json` is the repo-managed Cloud Agent config — the +highest-precedence environment source, so it applies to new agents with no +dashboard "Save". Keep the two lifecycle phases distinct: + +- `.cursor/install.sh` (`install`) — durable, idempotent, source-derived setup: + ensure `ffmpeg`/`ffprobe`, add the `rustfmt` component **fail-closed** (the + pinned toolchain is `profile = minimal`), install Python deps **hash-locked** + from `requirements-lock.txt` + `fuzz/requirements-dev.txt` and then the + package with `-e . --no-deps` (mirrors CI's supply-chain contract; never + resolve deps from the index unpinned), and build the `codec-carver-core` + release binary. It `cd`s to the repo root so it is CWD-independent. +- `.cursor/start.sh` (`start`) — per-boot reconciliation: rebuild the Rust + binary if a fresh checkout wiped `rust-core/target`, then start the FastAPI + service on `:8000` and **block until `GET /health` is ready (ready-or-fail)** + with time-bounded probes, so a dead worker is a failed boot rather than a + green start over a dead port. + +Put durable, source-derived setup in `install.sh`; put per-boot service startup +in `start.sh`. `GET /health` is a cheap, auth-exempt readiness probe used by +both `start.sh` and the Docker `HEALTHCHECK`. diff --git a/Dockerfile b/Dockerfile index a7ee8ee8..574e9a60 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,8 +22,8 @@ USER appuser EXPOSE 8000 -# Liveness: the FastAPI app serves the upload UI at "/". +# Liveness: the FastAPI app serves a cheap JSON probe at "/health". HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/', timeout=4).status==200 else 1)" + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=4).status==200 else 1)" CMD ["uvicorn", "saas_web:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/saas_web.py b/saas_web.py index 63265e94..64f64bc2 100644 --- a/saas_web.py +++ b/saas_web.py @@ -100,18 +100,20 @@ def get_configured_api_keys(): @app.middleware("http") async def require_api_key(request: Request, call_next): - """Enforce opt-in API-key authentication on all endpoints except GET /. + """Enforce opt-in API-key authentication on all endpoints except GET / and GET /health. When one or more keys are configured via CODEC_CARVER_API_KEYS, every - request other than GET / (the upload UI page) must carry an X-API-Key - header matching a configured key; comparison uses hmac.compare_digest to - stay constant-time. Requests failing the check receive a 401 JSON error - without echoing any key material. When no keys are configured, all - requests pass through unchanged. + request other than GET / (the upload UI page) and GET /health (the liveness + probe) must carry an X-API-Key header matching a configured key; comparison + uses hmac.compare_digest to stay constant-time. Requests failing the check + receive a 401 JSON error without echoing any key material. When no keys are + configured, all requests pass through unchanged. """ configured_keys = get_configured_api_keys() - if configured_keys and not (request.method == "GET" and request.url.path == "/"): + if configured_keys and not ( + request.method == "GET" and request.url.path in ("/", "/health") + ): provided_key = request.headers.get("x-api-key", "") if not any( hmac.compare_digest(provided_key, key) for key in configured_keys @@ -512,6 +514,18 @@ async def get_ui(): return HTML_TEMPLATE +@app.get("/health") +async def health(): + """Return a lightweight liveness/readiness probe. + + Auth-exempt (like GET /) and intentionally cheap — no HTML rendering or + disk/subprocess work — so the container HEALTHCHECK and the environment + start script can poll it as a fast readiness signal. + """ + + return {"status": "ok"} + + @app.post("/shrink") def shrink_media( background_tasks: BackgroundTasks, diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index 3b57e033..4e836654 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -37,6 +37,11 @@ def test_get_ui(self): self.assertEqual(response.status_code, 200) self.assertIn(b"Codec Carver SaaS", response.content) + def test_health_returns_ok(self): + response = client.get("/health") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"status": "ok"}) + def test_get_ui_includes_accessible_file_input_helpers(self): response = client.get("/") self.assertEqual(response.status_code, 200) @@ -732,6 +737,13 @@ def test_get_ui_always_open_without_key(self): self.assertEqual(response.status_code, 200) self.assertIn(b"Codec Carver SaaS", response.content) + def test_health_always_open_without_key(self): + with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): + response = client.get("/health") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"status": "ok"}) + def test_job_api_requires_key_when_configured(self): with patch.dict(os.environ, {"CODEC_CARVER_API_KEYS": "secret-key"}): response = client.get("/jobs/missing")