265 lines
11 KiB
Bash
Executable file
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)"
|