199 lines
8.7 KiB
Bash
Executable file
199 lines
8.7 KiB
Bash
Executable file
#!/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"
|
|
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
|
|
set -o allexport
|
|
# shellcheck disable=SC1090
|
|
source "$ENV_FILE"
|
|
set +o allexport
|
|
|
|
APP_PORT="${APP_PORT:-8100}"
|
|
|
|
# Warn about blank required keys
|
|
MISSING=""
|
|
for KEY in GEMINI_API_KEY ADMIN_EMAIL SESSION_SECRET; do
|
|
VAL="${!KEY:-}"
|
|
[[ -z "$VAL" ]] && MISSING="$MISSING $KEY"
|
|
done
|
|
[[ -n "$MISSING" ]] && warn "These required 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
|
|
# Port is in use — check if it's our own container
|
|
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 (with cache, refresh base images) ─────────────────────────
|
|
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
|
|
|
|
# Build only the Node.js frontend-builder stage (no Python/LibreOffice — fast)
|
|
docker build --target frontend-builder --tag "$FE_BUILD_TAG" "$APP_DIR"
|
|
|
|
# Copy dist/ out of the image via a throwaway container
|
|
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"
|
|
# Wipe old files (preserve directory itself so Apache doesn't break mid-deploy)
|
|
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 container ──────────────────────────────────────────────────────
|
|
log "Restarting application container..."
|
|
docker compose down --remove-orphans 2>/dev/null || true
|
|
docker compose up -d
|
|
ok "Container started"
|
|
|
|
# ── 10. 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
|
|
|
|
# ── 11. First-run initialisation ──────────────────────────────────────────────
|
|
DATA_DIR="$APP_DIR/data"
|
|
if [[ ! -f "$DATA_DIR/users.json" ]]; then
|
|
log "First run — initialising data directory..."
|
|
mkdir -p "$DATA_DIR/uploads" "$DATA_DIR/outputs" "$DATA_DIR/sheets"
|
|
# Trigger the app's startup seeding (dropdowns.json + users.json are created
|
|
# automatically on the first API request if they don't exist)
|
|
curl -sf "http://localhost:${APP_PORT}/api/dropdowns/categories" >/dev/null 2>&1 || true
|
|
ok "Data directory initialised at $DATA_DIR"
|
|
fi
|
|
|
|
# ── 12. Apache config reminder ────────────────────────────────────────────────
|
|
if ! grep -rq "ac-helper" /etc/apache2/sites-enabled/ 2>/dev/null; then
|
|
echo ""
|
|
hr
|
|
echo " Apache config not detected — add this inside your VirtualHost block:"
|
|
hr
|
|
cat <<APACHE
|
|
|
|
# Required: a2enmod proxy proxy_http proxy_wstunnel
|
|
|
|
# Proxy API to Docker
|
|
<Location /ac-helper/api/>
|
|
ProxyPass http://localhost:${APP_PORT}/api/
|
|
ProxyPassReverse http://localhost:${APP_PORT}/api/
|
|
</Location>
|
|
|
|
# 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/
|
|
<Directory /var/www/html/ac-helper>
|
|
Options -Indexes
|
|
AllowOverride None
|
|
Require all granted
|
|
FallbackResource /ac-helper/index.html
|
|
</Directory>
|
|
|
|
APACHE
|
|
hr
|
|
fi
|
|
|
|
# ── Summary ───────────────────────────────────────────────────────────────────
|
|
echo ""
|
|
ok "Deployment complete!"
|
|
echo " Container: docker logs -f $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 ""
|