sandbox-notebookllamalm-nextjs/scripts/2_deploy.sh
Vadym Samoilenko 23494552e7 Fix health check URL: /health -> /api/health
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:50:32 +00:00

265 lines
11 KiB
Bash
Executable file

#!/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)"