#!/usr/bin/env bash # ============================================================================= # predeployment-check.sh # # Run on the production server to capture full environment state before making # changes. Safe — read-only, no writes, no restarts. # # Usage (from local machine): # ssh root@ 'bash -s' < deploy/predeployment-check.sh # # Usage (on the server directly): # bash /srv/lean101-clients/deploy/predeployment-check.sh # ============================================================================= set -euo pipefail REMOTE_PATH="${REMOTE_PATH:-/srv/lean101-clients}" COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.production.yml}" ENV_FILE="${ENV_FILE:-.env.production}" BACKEND_CONTAINER="lean101-clients-backend" FRONTEND_CONTAINER="lean101-clients-frontend" NGINX_CONTAINER="lean101-clients" DB_CONTAINER="lean101-clients-db" # Colours RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m' CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' sep() { echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"; } h1() { sep; echo -e "${BOLD}${CYAN} $1${RESET}"; sep; } ok() { echo -e " ${GREEN}✔${RESET} $1"; } warn() { echo -e " ${YELLOW}⚠${RESET} $1"; } fail() { echo -e " ${RED}✘${RESET} $1"; } kv() { printf " %-30s %s\n" "$1" "$2"; } # Helper: run a command inside a container, return empty string on failure cexec() { local container="$1"; shift docker exec "$container" "$@" 2>/dev/null || true } echo "" echo -e "${BOLD} LEAN 101 CLIENTS — Pre-Deployment Check${RESET}" echo -e " Generated: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" echo -e " Host: $(hostname -f 2>/dev/null || hostname)" echo "" # ============================================================================= h1 "1. HOST SYSTEM" # ============================================================================= kv "OS:" "$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)" kv "Kernel:" "$(uname -r)" kv "Uptime:" "$(uptime -p 2>/dev/null || uptime)" echo "" echo " Disk usage (df -h):" df -h --output=source,size,used,avail,pcent,target 2>/dev/null | grep -v tmpfs | grep -v udev | sed 's/^/ /' echo "" echo " Memory (free -h):" free -h 2>/dev/null | sed 's/^/ /' || vm_stat 2>/dev/null | sed 's/^/ /' # ============================================================================= h1 "2. DOCKER & COMPOSE" # ============================================================================= if command -v docker &>/dev/null; then ok "Docker installed" kv "Docker version:" "$(docker --version)" kv "Compose version:" "$(docker compose version 2>/dev/null || docker-compose --version 2>/dev/null || echo 'not found')" else fail "Docker not found on PATH" fi # ============================================================================= h1 "3. REPOSITORY STATE" # ============================================================================= if [ -d "$REMOTE_PATH/.git" ]; then cd "$REMOTE_PATH" ok "Repo found at $REMOTE_PATH" kv "Branch:" "$(git rev-parse --abbrev-ref HEAD 2>/dev/null)" kv "Latest commit:" "$(git log -1 --format='%h %s (%cr)' 2>/dev/null)" kv "Commit author:" "$(git log -1 --format='%an <%ae>' 2>/dev/null)" kv "Remote origin:" "$(git remote get-url origin 2>/dev/null)" DIRTY=$(git status --porcelain 2>/dev/null) if [ -n "$DIRTY" ]; then warn "Working tree has uncommitted changes:" git status --short | sed 's/^/ /' else ok "Working tree clean" fi echo "" echo " Recent commits (last 5):" git log -5 --format=' %h %s (%cr)' 2>/dev/null else fail "No git repo found at $REMOTE_PATH" fi # ============================================================================= h1 "4. ENVIRONMENT FILE" # ============================================================================= ENV_PATH="$REMOTE_PATH/$ENV_FILE" if [ -f "$ENV_PATH" ]; then ok "Env file: $ENV_PATH" kv "Modified:" "$(stat -c '%y' "$ENV_PATH" 2>/dev/null | cut -d'.' -f1 || stat -f '%Sm' "$ENV_PATH" 2>/dev/null)" kv "Permissions:" "$(stat -c '%a %U:%G' "$ENV_PATH" 2>/dev/null || stat -f '%Sp %Su:%Sg' "$ENV_PATH" 2>/dev/null)" echo "" echo " Env keys present (values redacted):" grep -v '^#' "$ENV_PATH" | grep '=' | cut -d'=' -f1 | sort | sed 's/^/ /' else fail "Env file NOT found at $ENV_PATH" fi # ============================================================================= h1 "5. DOCKER STACK — CONTAINER STATUS" # ============================================================================= cd "$REMOTE_PATH" 2>/dev/null || true echo " All containers on this host:" docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}' | sed 's/^/ /' echo "" echo " Compose stack status ($COMPOSE_FILE):" docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" ps 2>/dev/null | sed 's/^/ /' || \ warn "Could not run docker compose ps (compose file or env file missing?)" # Per-container inspection for CNAME in "$BACKEND_CONTAINER" "$FRONTEND_CONTAINER" "$NGINX_CONTAINER" "$DB_CONTAINER"; do STATUS=$(docker inspect --format='{{.State.Status}}' "$CNAME" 2>/dev/null || echo "missing") HEALTH=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}n/a{{end}}' "$CNAME" 2>/dev/null || echo "missing") IMAGE=$(docker inspect --format='{{.Config.Image}}' "$CNAME" 2>/dev/null || echo "unknown") STARTED=$(docker inspect --format='{{.State.StartedAt}}' "$CNAME" 2>/dev/null | cut -d'.' -f1 || echo "unknown") echo "" echo -e " ${BOLD}$CNAME${RESET}" kv " Status:" "$STATUS" kv " Health:" "$HEALTH" kv " Image:" "$IMAGE" kv " Started:" "$STARTED" if [ "$STATUS" = "running" ]; then MEM=$(docker stats "$CNAME" --no-stream --format '{{.MemUsage}}' 2>/dev/null || echo "n/a") CPU=$(docker stats "$CNAME" --no-stream --format '{{.CPUPerc}}' 2>/dev/null || echo "n/a") kv " CPU / Mem:" "$CPU / $MEM" fi done # ============================================================================= h1 "6. DOCKER VOLUMES" # ============================================================================= echo " Named volumes:" docker volume ls --format 'table {{.Name}}\t{{.Driver}}\t{{.Mountpoint}}' | grep -i lean | sed 's/^/ /' || echo " (none matching lean)" echo "" echo " All volumes:" docker volume ls --format ' {{.Name}}' | head -30 # Postgres data volume size PG_MOUNT=$(docker volume inspect lean101-clients_clients_db_data --format '{{.Mountpoint}}' 2>/dev/null || \ docker volume inspect clients_db_data --format '{{.Mountpoint}}' 2>/dev/null || true) if [ -n "$PG_MOUNT" ]; then PG_SIZE=$(du -sh "$PG_MOUNT" 2>/dev/null | cut -f1 || echo "n/a") kv " Postgres volume size:" "$PG_SIZE ($PG_MOUNT)" fi # ============================================================================= h1 "7. POSTGRESQL — DATABASE STATE" # ============================================================================= DB_STATUS=$(docker inspect --format='{{.State.Status}}' "$DB_CONTAINER" 2>/dev/null || echo "missing") if [ "$DB_STATUS" = "running" ]; then ok "Database container is running" PG_USER=$(docker exec "$DB_CONTAINER" printenv POSTGRES_USER 2>/dev/null || echo "lean101") PG_DB=$(docker exec "$DB_CONTAINER" printenv POSTGRES_DB 2>/dev/null || echo "lean101") echo "" echo " PostgreSQL version:" cexec "$DB_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -c "SELECT version();" | sed 's/^/ /' echo "" echo " Database size:" cexec "$DB_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" \ -c "SELECT pg_database.datname, pg_size_pretty(pg_database_size(pg_database.datname)) AS size FROM pg_database ORDER BY pg_database_size(pg_database.datname) DESC;" \ | sed 's/^/ /' echo "" echo " Tables and row counts:" cexec "$DB_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -c " SELECT schemaname, relname AS table_name, n_live_tup AS row_count, pg_size_pretty(pg_total_relation_size(quote_ident(schemaname)||'.'||quote_ident(relname))) AS total_size FROM pg_stat_user_tables ORDER BY n_live_tup DESC; " | sed 's/^/ /' echo "" echo " Alembic migration state:" cexec "$DB_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -c \ "SELECT version_num, is_current FROM alembic_version LEFT JOIN (SELECT true AS is_current) t ON true;" \ 2>/dev/null | sed 's/^/ /' || \ cexec "$DB_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -c \ "SELECT version_num FROM alembic_version;" 2>/dev/null | sed 's/^/ /' || \ warn "alembic_version table not found or inaccessible" echo "" echo " Active connections:" cexec "$DB_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -c " SELECT count(*) AS total_connections, sum(CASE WHEN state = 'active' THEN 1 ELSE 0 END) AS active FROM pg_stat_activity WHERE datname = '$PG_DB'; " | sed 's/^/ /' else fail "Database container status: $DB_STATUS — skipping DB checks" fi # ============================================================================= h1 "8. BACKEND — HEALTH & API" # ============================================================================= if [ "$(docker inspect --format='{{.State.Status}}' "$BACKEND_CONTAINER" 2>/dev/null)" = "running" ]; then echo " Health endpoint (internal):" HEALTH_RESP=$(cexec "$BACKEND_CONTAINER" python -c \ "import urllib.request, json; r=urllib.request.urlopen('http://127.0.0.1:8000/health'); print(r.read().decode())" 2>/dev/null || echo "failed") echo " $HEALTH_RESP" echo "" echo " Python / package versions inside backend container:" cexec "$BACKEND_CONTAINER" python --version 2>&1 | sed 's/^/ /' cexec "$BACKEND_CONTAINER" pip show fastapi sqlalchemy alembic psycopg 2>/dev/null | \ grep -E '^(Name|Version):' | sed 's/^/ /' echo "" echo " DATABASE_URL in backend (secret masked):" cexec "$BACKEND_CONTAINER" printenv DATABASE_URL | \ sed 's|://[^:]*:[^@]*@|://***:***@|g' | sed 's/^/ /' else fail "Backend container not running — skipping API checks" fi # ============================================================================= h1 "9. NGINX — CONFIGURATION" # ============================================================================= if [ "$(docker inspect --format='{{.State.Status}}' "$NGINX_CONTAINER" 2>/dev/null)" = "running" ]; then ok "Nginx container is running" echo "" echo " nginx -t output:" cexec "$NGINX_CONTAINER" nginx -t 2>&1 | sed 's/^/ /' echo "" echo " Active listening ports (nginx container):" docker exec "$NGINX_CONTAINER" sh -c 'cat /etc/nginx/conf.d/default.conf 2>/dev/null | grep -E "listen|server_name|proxy_pass"' | sed 's/^/ /' || true echo "" echo " Host port binding:" docker port "$NGINX_CONTAINER" | sed 's/^/ /' else warn "Nginx container not running" fi # ============================================================================= h1 "10. RECENT CONTAINER LOGS (last 30 lines each)" # ============================================================================= for CNAME in "$BACKEND_CONTAINER" "$FRONTEND_CONTAINER" "$NGINX_CONTAINER" "$DB_CONTAINER"; do STATUS=$(docker inspect --format='{{.State.Status}}' "$CNAME" 2>/dev/null || echo "missing") echo "" echo -e " ${BOLD}$CNAME${RESET} [$STATUS]" if [ "$STATUS" != "missing" ]; then docker logs "$CNAME" --tail=30 2>&1 | sed 's/^/ /' fi done # ============================================================================= h1 "11. HOST NETWORK & FIREWALL" # ============================================================================= echo " Listening ports on host (ss -tlnp):" ss -tlnp 2>/dev/null | sed 's/^/ /' || \ netstat -tlnp 2>/dev/null | sed 's/^/ /' || \ warn "Neither ss nor netstat available" echo "" echo " UFW status:" ufw status 2>/dev/null | sed 's/^/ /' || warn "UFW not available" echo "" echo " Docker network list:" docker network ls | sed 's/^/ /' # ============================================================================= h1 "12. COMPOSE FILE DIFF (local vs what would deploy)" # ============================================================================= echo " Compose file on server ($COMPOSE_FILE):" if [ -f "$REMOTE_PATH/$COMPOSE_FILE" ]; then kv " Modified:" "$(stat -c '%y' "$REMOTE_PATH/$COMPOSE_FILE" 2>/dev/null | cut -d'.' -f1)" kv " Size:" "$(wc -l < "$REMOTE_PATH/$COMPOSE_FILE") lines" ok "File exists" else fail "$COMPOSE_FILE not found at $REMOTE_PATH" fi # ============================================================================= sep echo -e "${BOLD} Check complete — paste this output into Claude for analysis.${RESET}" sep echo ""