#!/usr/bin/env bash # scripts/2_deploy.sh — Step 2: Migrate NotebookLlama to Docker # Run AFTER 1_backup.sh. Stops systemd services and brings up Docker Compose. set -euo pipefail REPO_DIR="/opt/sandbox-notebookllamalm-nextjs" POSTGRES_CONTAINER="sandbox-nextjs-postgres" VOLUME_PREFIX="sandbox-notebookllamalm-nextjs_" # Read postgres credentials from backend .env (keys: pgql_user, pgql_psw, pgql_db) _read_env() { grep -E "^${1}=" "${REPO_DIR}/backend/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"'"'" ; } PG_USER=$(_read_env pgql_user) PG_PSW=$(_read_env pgql_psw) PG_DB=$(_read_env pgql_db) BACKEND_SVC="notebookllama-backend" FRONTEND_SVC="notebookllama-frontend" BACKEND_PORT=9000 FRONTEND_PORT=4000 HEALTH_RETRIES=5 HEALTH_INTERVAL=5 # seconds between retries # ── Colors ────────────────────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' info() { echo -e "${GREEN}[INFO]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } step() { echo -e "\n${CYAN}══ $* ${NC}"; } # ── Rollback hint ───────────────────────────────────────────────────────────── print_rollback() { echo "" echo -e "${YELLOW}── Rollback instructions ─────────────────────────────────────────${NC}" echo " 1. Stop Docker services: docker compose -f ${REPO_DIR}/docker-compose.yml down" echo " 2. Restart systemd: sudo systemctl start ${BACKEND_SVC} ${FRONTEND_SVC}" echo " 3. Check status: sudo systemctl status ${BACKEND_SVC} ${FRONTEND_SVC}" echo -e "${YELLOW}──────────────────────────────────────────────────────────────────${NC}" } # On any unexpected error, print rollback hint and logs on_error() { local exit_code=$? echo "" error "Deploy failed at line ${BASH_LINENO[0]} (exit code ${exit_code})" echo "" warn "Showing last 30 lines of Docker Compose logs..." docker compose -f "${REPO_DIR}/docker-compose.yml" logs --tail=30 2>/dev/null || true print_rollback exit $exit_code } trap on_error ERR # ── Step 0: Preflight checks ────────────────────────────────────────────────── step "0 / Preflight checks" # Must be run from repo dir or repo dir must exist if [[ "$(pwd)" != "$REPO_DIR" && ! -d "$REPO_DIR" ]]; then error "Repo not found at ${REPO_DIR}" exit 1 fi cd "$REPO_DIR" info "Working directory: $(pwd)" if ! docker info &>/dev/null; then error "Docker is not available" exit 1 fi info "Docker: OK" if ! docker compose version &>/dev/null; then error "docker compose (v2) not found" exit 1 fi info "Docker Compose v2: OK" if ! docker ps --format '{{.Names}}' | grep -q "^${POSTGRES_CONTAINER}$"; then warn "Postgres container '${POSTGRES_CONTAINER}' is not running." warn "If this is the first deploy, it will be created by Docker Compose — continuing." else info "Postgres container: running" fi # ── Step 1: Move MP3 files ──────────────────────────────────────────────────── step "1 / Move MP3 files to backend/conversations/" sudo mkdir -p backend/conversations MP3_COUNT=$(find backend -maxdepth 1 -name 'conversation_*.mp3' 2>/dev/null | wc -l | tr -d ' ') if [[ "$MP3_COUNT" -gt 0 ]]; then info "Moving ${MP3_COUNT} MP3 file(s)..." sudo mv backend/conversation_*.mp3 backend/conversations/ info "Moved to backend/conversations/" else info "No MP3 files in backend root — nothing to move" fi # ── Step 2: Update podcast paths in DB ─────────────────────────────────────── step "2 / Update podcast_path in PostgreSQL (prepend conversations/)" # Only update rows where path does NOT already start with 'conversations/' if docker ps --format '{{.Names}}' | grep -q "^${POSTGRES_CONTAINER}$"; then UPDATED=$(docker exec -e PGPASSWORD="$PG_PSW" "$POSTGRES_CONTAINER" \ psql -U "$PG_USER" "$PG_DB" -c \ "UPDATE notebooks SET podcast_path = 'conversations/' || podcast_path WHERE podcast_path IS NOT NULL AND podcast_path NOT LIKE 'conversations/%' AND podcast_path NOT LIKE 'http%';" 2>/dev/null \ | grep -oE 'UPDATE [0-9]+' | awk '{print $2}' || echo "0") info "Updated ${UPDATED} row(s) in notebooks.podcast_path" else warn "Postgres not running — skipping DB path update" warn "You may need to run this manually after the migration:" echo " docker exec -it ${POSTGRES_CONTAINER} psql -U \$pgql_user \$pgql_db -c \\" echo " \"UPDATE notebooks SET podcast_path = 'conversations/' || podcast_path" echo " WHERE podcast_path IS NOT NULL AND podcast_path NOT LIKE 'conversations/%';\"" fi # ── Step 3: git pull ────────────────────────────────────────────────────────── step "3 / git pull origin main" # Stash any local changes (e.g. created conversations/ dir) before pulling git stash --quiet 2>/dev/null || true git pull origin main info "Repository updated" # ── Step 4: docker compose build ───────────────────────────────────────────── step "4 / docker compose build" docker compose build info "Images built successfully" # ── Step 5: Stop systemd services ──────────────────────────────────────────── step "5 / Stop systemd services" for svc in "$BACKEND_SVC" "$FRONTEND_SVC"; do if sudo systemctl is-active --quiet "$svc" 2>/dev/null; then info "Stopping ${svc}..." sudo systemctl stop "$svc" info " → stopped" else info "${svc} is not active (already stopped or not installed)" fi done # Brief pause to ensure ports are freed sleep 2 # ── Step 6: Start postgres + redis (creates volumes) ───────────────────────── step "6 / Start postgres and redis" docker compose up -d postgres redis info "Waiting 5 seconds for postgres to be ready..." sleep 5 # ── Step 7: Copy data into Docker volumes ──────────────────────────────────── step "7 / Copy data into Docker volumes" PODCASTS_VOLUME="${VOLUME_PREFIX}podcasts_data" UPLOADS_VOLUME="${VOLUME_PREFIX}uploads_data" # podcasts_data ← backend/conversations/ CONV_DIR="backend/conversations" if [[ -d "$CONV_DIR" ]] && [[ -n "$(ls -A "$CONV_DIR" 2>/dev/null)" ]]; then info "Copying ${CONV_DIR}/ → volume ${PODCASTS_VOLUME}..." docker run --rm \ -v "$(pwd)/${CONV_DIR}:/source:ro" \ -v "${PODCASTS_VOLUME}:/dest" \ alpine \ sh -c 'cp -a /source/. /dest/' COUNT=$(docker run --rm -v "${PODCASTS_VOLUME}:/d:ro" alpine sh -c 'ls /d | wc -l') info " → ${COUNT} file(s) in podcasts_data volume" else info "No files in ${CONV_DIR} — podcasts_data volume will be empty" fi # uploads_data ← backend/failed_uploads/ UPLOADS_DIR="backend/failed_uploads" if [[ -d "$UPLOADS_DIR" ]] && [[ -n "$(ls -A "$UPLOADS_DIR" 2>/dev/null)" ]]; then info "Copying ${UPLOADS_DIR}/ → volume ${UPLOADS_VOLUME}..." docker run --rm \ -v "$(pwd)/${UPLOADS_DIR}:/source:ro" \ -v "${UPLOADS_VOLUME}:/dest" \ alpine \ sh -c 'cp -a /source/. /dest/' info " → done" else info "No files in ${UPLOADS_DIR} — uploads_data volume will be empty" fi # ── Step 8: Start all services ──────────────────────────────────────────────── step "8 / docker compose up -d" docker compose up -d info "All containers started" # ── Step 9: Health checks ───────────────────────────────────────────────────── step "9 / Health checks" check_health() { local name="$1" local url="$2" local expected_code="${3:-200}" local attempt=0 while (( attempt < HEALTH_RETRIES )); do attempt=$(( attempt + 1 )) info " ${name}: attempt ${attempt}/${HEALTH_RETRIES} — ${url}" local code code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$url" 2>/dev/null || echo "000") if [[ "$code" == "$expected_code" ]]; then info " ${name}: ${GREEN}OK${NC} (HTTP ${code})" return 0 fi warn " ${name}: HTTP ${code}, retrying in ${HEALTH_INTERVAL}s..." sleep "$HEALTH_INTERVAL" done error "${name}: health check FAILED after ${HEALTH_RETRIES} attempts" return 1 } HEALTH_FAILED=0 check_health "Backend" "http://localhost:${BACKEND_PORT}/api/health" "200" || HEALTH_FAILED=1 check_health "Frontend" "http://localhost:${FRONTEND_PORT}/notebookllama" "200" || HEALTH_FAILED=1 if [[ "$HEALTH_FAILED" -eq 1 ]]; then error "One or more health checks failed." echo "" warn "systemd services were NOT disabled — you can roll back:" print_rollback exit 1 fi # ── Step 10: Disable systemd services ──────────────────────────────────────── step "10 / Disable systemd services" for svc in "$BACKEND_SVC" "$FRONTEND_SVC"; do if sudo systemctl is-enabled --quiet "$svc" 2>/dev/null; then sudo systemctl disable "$svc" info "${svc} disabled" else info "${svc} was not enabled (skipping)" fi done # ── Done ────────────────────────────────────────────────────────────────────── echo "" info "── Deploy complete ───────────────────────────────────────────────" echo "" docker compose ps echo "" info "Application is live:" echo " http://localhost:${FRONTEND_PORT}/notebookllama" echo " http://localhost:${BACKEND_PORT}/health" echo "" info "Run verification commands:" echo " docker compose ps" echo " curl -s http://localhost:${BACKEND_PORT}/api/health" echo " docker run --rm -v ${PODCASTS_VOLUME}:/d alpine ls /d" echo "" info "When verified, run step 3:" echo " bash ${REPO_DIR}/scripts/3_cleanup.sh" echo "" print_rollback info "Deploy finished at $(date)"