#!/usr/bin/env bash # deploy.sh — idempotent deployment script for ac-tool # Usage: sudo bash /opt/ac-tool/deploy.sh set -euo pipefail # ── Config ──────────────────────────────────────────────────────────────────── APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WEB_DIR="/var/www/html/ac-helper" WEB_USER="www-data" ENV_FILE="$APP_DIR/.env" ENV_EXAMPLE="$APP_DIR/.env.example" CONTAINER_NAME="ac-tool" DB_CONTAINER_NAME="ac-tool-db" FE_BUILD_TAG="ac-tool-fe-extract" # ── Colours ─────────────────────────────────────────────────────────────────── log() { echo -e "\033[1;34m▶\033[0m $*"; } ok() { echo -e "\033[1;32m✔\033[0m $*"; } warn() { echo -e "\033[1;33m⚠\033[0m $*"; } die() { echo -e "\033[1;31m✖\033[0m $*" >&2; exit 1; } hr() { echo "────────────────────────────────────────────────────────────"; } hr echo " AC Tool — deployment" echo " $(date '+%Y-%m-%d %H:%M:%S') | dir: $APP_DIR" hr # ── 1. Root check ───────────────────────────────────────────────────────────── if [[ $EUID -ne 0 ]]; then die "Run as root: sudo bash $0" fi # ── 2. Prerequisites ────────────────────────────────────────────────────────── log "Checking prerequisites..." command -v docker >/dev/null 2>&1 || die "Docker not installed" command -v git >/dev/null 2>&1 || die "Git not installed" docker compose version >/dev/null 2>&1 || die "Docker Compose plugin not found (need 'docker compose', not 'docker-compose')" ok "Prerequisites OK" # ── 3. .env setup ───────────────────────────────────────────────────────────── log "Checking .env..." if [[ ! -f "$ENV_FILE" ]]; then [[ ! -f "$ENV_EXAMPLE" ]] && die ".env.example not found in $APP_DIR" cp "$ENV_EXAMPLE" "$ENV_FILE" warn ".env created from .env.example" warn "Fill in the required values and re-run:" warn " nano $ENV_FILE" exit 0 fi # Load .env into the current shell so we can read APP_PORT etc. set -o allexport # shellcheck disable=SC1090 source "$ENV_FILE" set +o allexport APP_PORT="${APP_PORT:-8100}" POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" # Warn about blank required keys MISSING="" for KEY in GEMINI_API_KEY ADMIN_EMAIL SESSION_SECRET POSTGRES_PASSWORD; do VAL="${!KEY:-}" [[ -z "$VAL" ]] && MISSING="$MISSING $KEY" done [[ -n "$MISSING" ]] && warn "These keys are empty in .env:$MISSING" ok ".env loaded (APP_PORT=$APP_PORT)" # ── 4. Port check ───────────────────────────────────────────────────────────── log "Checking port $APP_PORT..." if ss -tlnp | grep -q ":${APP_PORT} "; then if docker ps --format '{{.Names}} {{.Ports}}' | grep -q "${CONTAINER_NAME}.*${APP_PORT}"; then warn "Port $APP_PORT already used by our container (will be restarted)" else die "Port $APP_PORT is occupied by another process. Change APP_PORT in .env or free the port." fi else ok "Port $APP_PORT is free" fi # ── 5. Git pull ─────────────────────────────────────────────────────────────── # Run git as the invoking user (not root) so SSH keys work GIT_USER="${SUDO_USER:-$(whoami)}" log "Pulling latest code (as $GIT_USER)..." cd "$APP_DIR" sudo -u "$GIT_USER" git fetch origin LOCAL=$(git rev-parse HEAD) REMOTE=$(git rev-parse '@{u}') if [[ "$LOCAL" == "$REMOTE" ]]; then ok "Already up to date ($(git rev-parse --short HEAD))" else sudo -u "$GIT_USER" git pull --ff-only || die "git pull failed — local changes detected. Stash or reset them first." ok "Updated to $(git rev-parse --short HEAD)" fi # ── 6. Docker build ─────────────────────────────────────────────────────────── log "Building Docker image..." docker compose build --pull ok "Docker image built" # ── 7. Extract frontend from Docker build stage ─────────────────────────────── log "Building and extracting frontend..." TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"; docker rmi "$FE_BUILD_TAG" >/dev/null 2>&1 || true' EXIT docker build --target frontend-builder --tag "$FE_BUILD_TAG" "$APP_DIR" EXTRACT_CONTAINER=$(docker create "$FE_BUILD_TAG") docker cp "$EXTRACT_CONTAINER:/app/frontend/dist/." "$TMPDIR/" docker rm "$EXTRACT_CONTAINER" >/dev/null FILE_COUNT=$(find "$TMPDIR" -type f | wc -l) ok "Frontend built: $FILE_COUNT files" # ── 8. Deploy frontend static files ─────────────────────────────────────────── log "Deploying frontend to $WEB_DIR..." mkdir -p "$WEB_DIR" find "${WEB_DIR}" -mindepth 1 -delete cp -r "$TMPDIR/." "$WEB_DIR/" chown -R "$WEB_USER:$WEB_USER" "$WEB_DIR" chmod -R 755 "$WEB_DIR" ok "Frontend deployed to $WEB_DIR" # ── 9. Restart containers ───────────────────────────────────────────────────── log "Restarting containers (app + postgres)..." docker compose down --remove-orphans 2>/dev/null || true docker compose up -d ok "Containers started" # ── 10. Wait for PostgreSQL ──────────────────────────────────────────────────── log "Waiting for PostgreSQL to be ready..." for i in $(seq 1 30); do if docker exec "$DB_CONTAINER_NAME" pg_isready -U achelper -d achelper >/dev/null 2>&1; then ok "PostgreSQL is ready" break fi if [[ $i -eq 30 ]]; then die "PostgreSQL did not become ready after 60s. Check logs: docker logs $DB_CONTAINER_NAME" fi sleep 2 done # ── 11. Health check ────────────────────────────────────────────────────────── log "Waiting for application to be healthy..." HEALTH_URL="http://localhost:${APP_PORT}/health" for i in $(seq 1 30); do if curl -sf "$HEALTH_URL" >/dev/null 2>&1; then ok "Application is healthy" break fi if [[ $i -eq 30 ]]; then die "Health check failed after 60s. Check logs: docker logs $CONTAINER_NAME" fi sleep 2 done # ── 12. Data directory ──────────────────────────────────────────────────────── DATA_DIR="$APP_DIR/data" mkdir -p "$DATA_DIR/uploads" "$DATA_DIR/outputs" ok "Data directories ready at $DATA_DIR" # ── 13. JSON → PostgreSQL migration (first deploy after adding Postgres) ─────── # # If old JSON data files exist, offer to run the one-time migration script # that imports users, clients, dropdowns, sheets, and export templates into DB. # JSON_MIGRATION_MARKER="$DATA_DIR/.pg_migrated" if [[ ! -f "$JSON_MIGRATION_MARKER" ]]; then HAS_JSON=false for f in "$DATA_DIR/users.json" "$DATA_DIR/sheets_metadata.json" "$DATA_DIR/clients.json"; do [[ -f "$f" ]] && HAS_JSON=true && break done if [[ "$HAS_JSON" == "true" ]]; then echo "" warn "Old JSON data files detected. Run the one-time migration to import them into PostgreSQL?" warn " yes — migrate now (recommended)" warn " no — skip (data already in DB or you'll migrate manually)" read -r -p " Migrate now? [yes/no]: " MIGRATE_ANSWER /dev/null; then echo "" hr echo " Apache config not detected — add this inside your VirtualHost block:" hr cat < ProxyPass http://localhost:${APP_PORT}/api/ ProxyPassReverse http://localhost:${APP_PORT}/api/ # Proxy WebSocket ProxyPass /ac-helper/ws ws://localhost:${APP_PORT}/ws ProxyPassReverse /ac-helper/ws ws://localhost:${APP_PORT}/ws # Serve frontend static files Alias /ac-helper/ /var/www/html/ac-helper/ Options -Indexes AllowOverride None Require all granted FallbackResource /ac-helper/index.html APACHE hr fi # ── Summary ─────────────────────────────────────────────────────────────────── echo "" ok "Deployment complete!" echo " App container: docker logs -f $CONTAINER_NAME" echo " DB container: docker logs -f $DB_CONTAINER_NAME" echo " Health: $HEALTH_URL" echo " Frontend: $WEB_DIR ($FILE_COUNT files)" echo " Data: $DATA_DIR" echo " Commit: $(git -C "$APP_DIR" rev-parse --short HEAD)" echo ""