diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 464f3e9..1267cbd 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -710,6 +710,7 @@ jobs: script: | set -euo pipefail export DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}" + export INFRA_ROOT="${INFRA_ROOT:-/opt/infra}" [ -d "$DEPLOY_ROOT" ] || { echo "::error::DEPLOY_ROOT not found: $DEPLOY_ROOT"; exit 1; } cd "$DEPLOY_ROOT" # Pull latest scripts without full deploy @@ -775,6 +776,7 @@ jobs: set -euo pipefail T0=$(date +%s) export DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}" + export INFRA_ROOT="${INFRA_ROOT:-/opt/infra}" [ -d "$DEPLOY_ROOT" ] || { echo "::error::DEPLOY_ROOT not found: $DEPLOY_ROOT"; exit 1; } cd "$DEPLOY_ROOT" # Enforce repo is at the exact SHA being deployed (issue 7 — prevents @@ -794,6 +796,7 @@ jobs: key: ${{ secrets.DO_SSH_KEY }} script: | export DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}" + export INFRA_ROOT="${INFRA_ROOT:-/opt/infra}" ACTIVE_SLOT=$(cat "$DEPLOY_ROOT/.fieldtrack/active-slot" 2>/dev/null || cat /var/lib/fieldtrack/active-slot 2>/dev/null || echo "unknown") ACTIVE_CONTAINER="api-${ACTIVE_SLOT}" DEPLOY_STATUS="UNKNOWN" @@ -836,6 +839,7 @@ jobs: script: | set -euo pipefail export DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}" + export INFRA_ROOT="${INFRA_ROOT:-/opt/infra}" [ -d "$DEPLOY_ROOT" ] || { echo "::error::DEPLOY_ROOT not found: $DEPLOY_ROOT"; exit 1; } cd "$DEPLOY_ROOT" @@ -914,6 +918,7 @@ jobs: script: | set -euo pipefail export DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}" + export INFRA_ROOT="${INFRA_ROOT:-/opt/infra}" [ -d "$DEPLOY_ROOT" ] || { echo "::error::DEPLOY_ROOT not found: $DEPLOY_ROOT"; exit 1; } cd "$DEPLOY_ROOT" API_BASE_URL=$(grep -E '^API_BASE_URL=' .env 2>/dev/null | head -1 | cut -d'=' -f2- || true) @@ -951,6 +956,7 @@ jobs: script: | set -euo pipefail export DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}" + export INFRA_ROOT="${INFRA_ROOT:-/opt/infra}" [ -d "$DEPLOY_ROOT" ] || { echo "::error::DEPLOY_ROOT not found: $DEPLOY_ROOT"; exit 1; } cd "$DEPLOY_ROOT" API_BASE_URL=$(grep -E '^API_BASE_URL=' .env 2>/dev/null | head -1 | cut -d'=' -f2- || true) @@ -992,6 +998,7 @@ jobs: key: ${{ secrets.DO_SSH_KEY }} script: | export DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}" + export INFRA_ROOT="${INFRA_ROOT:-/opt/infra}" ACTIVE_SLOT=$(cat "$DEPLOY_ROOT/.fieldtrack/active-slot" 2>/dev/null || cat /var/lib/fieldtrack/active-slot 2>/dev/null || echo "unknown") ACTIVE_CONTAINER="api-${ACTIVE_SLOT}" FT_CURL_IMG="curlimages/curl:8.7.1" @@ -1062,6 +1069,7 @@ jobs: script: | set -euo pipefail export DEPLOY_ROOT="${DEPLOY_ROOT:-$HOME/api}" + export INFRA_ROOT="${INFRA_ROOT:-/opt/infra}" [ -d "$DEPLOY_ROOT" ] || { echo "::error::DEPLOY_ROOT not found: $DEPLOY_ROOT"; exit 1; } cd "$DEPLOY_ROOT" chmod +x scripts/*.sh diff --git a/README.md b/README.md index d0d8650..c729f58 100644 --- a/README.md +++ b/README.md @@ -147,12 +147,11 @@ All variables are validated at startup by `src/config/env.ts` (Zod schema, fail- This API requires an external infra repository. -Expected on server: -- nginx (connected to `api_network`) -- Redis (`redis:6379`) - -Default path: -- `INFRA_ROOT=/opt/infra` +Expected on server (all under **`INFRA_ROOT=/opt/infra`**): +- `$INFRA_ROOT/docker-compose.nginx.yml` — operator runs nginx from here +- `$INFRA_ROOT/docker-compose.redis.yml` — operator runs Redis from here +- `$INFRA_ROOT/nginx/live`, `nginx/backup`, `nginx/api.conf` — layout enforced by `deploy.sh` and readiness check +- nginx container on `api_network`; Redis at `redis:6379` on `api_network` Deployments run automatically via GitHub Actions on every push to `master` (after CodeQL scan passes). diff --git a/docs/infra-contract.md b/docs/infra-contract.md index d7d6259..973c2ad 100644 --- a/docs/infra-contract.md +++ b/docs/infra-contract.md @@ -94,11 +94,14 @@ Default `INFRA_ROOT` on server: `/opt/infra` ## External Dependencies (infra repo) -Required paths under `INFRA_ROOT=/opt/infra`: +Required layout under **`INFRA_ROOT=/opt/infra`** (canonical; scripts default to this path): + ``` -$INFRA_ROOT/nginx/live/ (directory, created by infra) -$INFRA_ROOT/nginx/backup/ (directory, created by infra) -$INFRA_ROOT/nginx/api.conf (template, managed by infra) +$INFRA_ROOT/nginx/live/ (directory — deploy writes live api.conf) +$INFRA_ROOT/nginx/backup/ (directory — rolling backups) +$INFRA_ROOT/nginx/api.conf (template with placeholders — infra managed) +$INFRA_ROOT/docker-compose.nginx.yml (operator starts nginx from here) +$INFRA_ROOT/docker-compose.redis.yml (operator starts redis from here) ``` -The API deploy script (`scripts/deploy.sh`) fails fast if these are absent. +The API deploy script (`scripts/deploy.sh`) and `scripts/vps-readiness-check.sh` fail fast if these paths are missing. Compose files are not executed by deploy; they must exist so operators (and checks) know the canonical layout. diff --git a/infra/docker-compose.nginx.yml b/infra/docker-compose.nginx.yml index 82e4cba..9fd60de 100644 --- a/infra/docker-compose.nginx.yml +++ b/infra/docker-compose.nginx.yml @@ -1,3 +1,5 @@ +# Canonical on server: INFRA_ROOT=/opt/infra — place this file at $INFRA_ROOT/docker-compose.nginx.yml +# Volume ./nginx/live is relative to this file (→ $INFRA_ROOT/nginx/live when deployed). services: nginx: diff --git a/infra/docker-compose.redis.yml b/infra/docker-compose.redis.yml index 7043cc3..4ee9ffa 100644 --- a/infra/docker-compose.redis.yml +++ b/infra/docker-compose.redis.yml @@ -1,3 +1,4 @@ +# Canonical on server: INFRA_ROOT=/opt/infra — place this file at $INFRA_ROOT/docker-compose.redis.yml services: redis: diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 490ed2f..8ef9170 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -34,8 +34,11 @@ # - FIELDTRACK_STATE_DIR or /var/lib/fieldtrack when writable (sudo chown if needed) # - Otherwise $DEPLOY_ROOT/.fieldtrack; existing /var/lib/fieldtrack/* is migrated once # -# Nginx config paths (INFRA_ROOT, default /opt/infra): -# - deploy user must write $INFRA_ROOT/nginx/live and nginx/backup (sudo mkdir+chown if needed) +# Canonical infra root on VPS: INFRA_ROOT=/opt/infra (default). All paths below use $INFRA_ROOT. +# Nginx + compose files (all under INFRA_ROOT): +# - $INFRA_ROOT/nginx/live, nginx/backup (deploy user must be able to write live + backup) +# - $INFRA_ROOT/nginx/api.conf (template) +# - $INFRA_ROOT/docker-compose.nginx.yml, docker-compose.redis.yml (must exist; deploy does not start them) # ============================================================================= set -euo pipefail if [ "${DEBUG:-false}" = "true" ]; then set -x; fi @@ -175,7 +178,7 @@ _ft_snapshot() { "$(cat "${SLOT_BACKUP_FILE:-/var/lib/fieldtrack/active-slot.backup}" 2>/dev/null || echo 'MISSING')" >&2 printf '[DEPLOY] nginx_upstream = %s\n' \ "$(grep -oE 'http://(api-blue|api-green):3000' \ - "${NGINX_CONF:-/opt/infra/nginx/live/api.conf}" 2>/dev/null \ + "${NGINX_CONF:-${INFRA_ROOT:-/opt/infra}/nginx/live/api.conf}" 2>/dev/null \ | grep -oE 'api-blue|api-green' | head -1 || echo 'unreadable')" >&2 printf '[DEPLOY] containers =\n' >&2 docker ps --format '[DEPLOY] {{.Names}} -> {{.Status}} ({{.Ports}})' 1>&2 2>/dev/null \ @@ -499,21 +502,29 @@ ensure_network() { # ensure_nginx — nginx MUST exist and be on api_network; hard fail otherwise # --------------------------------------------------------------------------- ensure_nginx() { - if [ ! -d "$INFRA_ROOT/nginx/live" ]; then - _ft_error "msg='infra not initialized at expected path' infra_root=$INFRA_ROOT required=$INFRA_ROOT/nginx/live" + if [ ! -d "$NGINX_LIVE_DIR" ]; then + _ft_error "msg='infra not initialized' infra_root=$INFRA_ROOT required=$NGINX_LIVE_DIR" _ft_exit 1 "DEPLOY_FAILED_SAFE" "reason=infra_not_initialized" fi - if [ ! -d "$INFRA_ROOT/nginx/backup" ]; then - _ft_error "msg='infra not initialized at expected path' infra_root=$INFRA_ROOT required=$INFRA_ROOT/nginx/backup" + if [ ! -d "$NGINX_BACKUP_DIR" ]; then + _ft_error "msg='infra not initialized' infra_root=$INFRA_ROOT required=$NGINX_BACKUP_DIR" _ft_exit 1 "DEPLOY_FAILED_SAFE" "reason=infra_not_initialized" fi - if [ ! -f "$INFRA_ROOT/nginx/api.conf" ]; then - _ft_error "msg='infra template missing' path=$INFRA_ROOT/nginx/api.conf" + if [ ! -f "$NGINX_TEMPLATE" ]; then + _ft_error "msg='infra template missing' path=$NGINX_TEMPLATE" _ft_exit 1 "DEPLOY_FAILED_SAFE" "reason=infra_template_missing" fi + if [ ! -f "$INFRA_COMPOSE_NGINX" ]; then + _ft_error "msg='infra compose file missing' path=$INFRA_COMPOSE_NGINX" + _ft_exit 1 "DEPLOY_FAILED_SAFE" "reason=infra_compose_nginx_missing" + fi + if [ ! -f "$INFRA_COMPOSE_REDIS" ]; then + _ft_error "msg='infra compose file missing' path=$INFRA_COMPOSE_REDIS" + _ft_exit 1 "DEPLOY_FAILED_SAFE" "reason=infra_compose_redis_missing" + fi if ! docker inspect nginx >/dev/null 2>&1; then - _ft_error "msg='nginx container not found — nginx is managed by the infra repo' hint='docker compose -f docker-compose.nginx.yml up -d'" + _ft_error "msg='nginx container not found' infra_root=$INFRA_ROOT hint='docker compose -f $INFRA_COMPOSE_NGINX up -d'" _ft_exit 1 "DEPLOY_FAILED_SAFE" "reason=nginx_missing" fi local net @@ -1312,6 +1323,8 @@ NGINX_CONF="$INFRA_ROOT/nginx/live/api.conf" NGINX_LIVE_DIR="$INFRA_ROOT/nginx/live" NGINX_BACKUP_DIR="$INFRA_ROOT/nginx/backup" NGINX_TEMPLATE="$INFRA_ROOT/nginx/api.conf" +INFRA_COMPOSE_NGINX="$INFRA_ROOT/docker-compose.nginx.yml" +INFRA_COMPOSE_REDIS="$INFRA_ROOT/docker-compose.redis.yml" NGINX_BACKUP="" # set inside switch_nginx() MAX_HISTORY=5 diff --git a/scripts/verify-stabilization.sh b/scripts/verify-stabilization.sh index 840d2d6..72f4e92 100644 --- a/scripts/verify-stabilization.sh +++ b/scripts/verify-stabilization.sh @@ -40,15 +40,16 @@ check_not_exists "$SCRIPT_DIR/analytics-backfill.ts" check_not_exists "$SCRIPT_DIR/load-testing" # Infra coupling guard (deployment/runtime paths only) -# We only block local repo-relative infra paths (./infra or ../infra) in -# executable/deploy code paths. External absolute paths like /opt/infra are allowed. +# Block repo-relative ./infra/ or ../infra/ in scripts and src. Canonical server +# layout is INFRA_ROOT=/opt/infra (see docs/infra-contract.md). Workflows are +# not scanned here — they contain guard strings that mention ./infra/ by design. if grep -R -E "\.\./infra/|\./infra/" \ - "$REPO_ROOT/scripts" "$REPO_ROOT/src" "$REPO_ROOT/.github/workflows" \ + "$REPO_ROOT/scripts" "$REPO_ROOT/src" \ --exclude="verify-stabilization.sh" \ --binary-files=without-match --exclude-dir=node_modules --exclude-dir=.git >/dev/null; then - fail "Found local repo-relative infra coupling in scripts/src/workflows" + fail "Found local repo-relative infra coupling in scripts/ or src/" else - pass "No local repo-relative infra coupling found" + pass "No local repo-relative infra coupling in scripts/ or src/" fi # Deploy workflow guard diff --git a/scripts/vps-readiness-check.sh b/scripts/vps-readiness-check.sh index eb3be77..5ba813b 100644 --- a/scripts/vps-readiness-check.sh +++ b/scripts/vps-readiness-check.sh @@ -230,6 +230,13 @@ if [ ! -f "$INFRA_ROOT/nginx/api.conf" ]; then else ok "Nginx template present: $INFRA_ROOT/nginx/api.conf" fi +for compose in "$INFRA_ROOT/docker-compose.nginx.yml" "$INFRA_ROOT/docker-compose.redis.yml"; do + if [ ! -f "$compose" ]; then + record_failure "Missing compose file: $compose (expected under canonical INFRA_ROOT=$INFRA_ROOT)" + else + ok "Compose file present: $compose" + fi +done # ── CHECK 8: Network attachment enforcement ─────────────────────────────────── # @@ -268,7 +275,7 @@ echo "--- CHECK 9: Nginx container ---" if ! docker inspect nginx >/dev/null 2>&1; then record_failure "nginx container not found — required for deployment routing." echo " nginx must be running before deploy can proceed." - echo " Fix: docker compose -f docker-compose.nginx.yml up -d" + echo " Fix: cd \"$INFRA_ROOT\" && docker compose -f docker-compose.nginx.yml up -d" else ok "nginx container exists." # Advisory in-network health probe — nginx may return non-200 before