feat: Robust deploy pipeline v2.0
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled

- Multiple server addresses with automatic fallback
- SSH retry logic with exponential backoff
- Health checks before and after deployment
- HTTP-based deployment endpoint as backup
- Better error handling and logging
- Support for partial deployments (backend/frontend only)
This commit is contained in:
2025-12-18 10:43:50 +01:00
parent 871ee3f80e
commit a70439c51a
4 changed files with 876 additions and 215 deletions

View File

@ -30,6 +30,7 @@ from app.api.drops import router as drops_router
from app.api.llm import router as llm_router
from app.api.llm_naming import router as llm_naming_router
from app.api.llm_vision import router as llm_vision_router
from app.api.deploy import router as deploy_router
api_router = APIRouter()
@ -81,3 +82,6 @@ api_router.include_router(blog_router, prefix="/blog", tags=["Blog"])
# Admin endpoints
api_router.include_router(admin_router, prefix="/admin", tags=["Admin"])
# Deploy endpoint (internal use only)
api_router.include_router(deploy_router, tags=["Deploy"])

231
backend/app/api/deploy.py Normal file
View File

@ -0,0 +1,231 @@
"""
Remote Deploy Endpoint
This provides a secure way to trigger deployments remotely when SSH is not available.
Protected by an internal API key that should be kept secret.
"""
import asyncio
import logging
import os
import subprocess
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Header, BackgroundTasks
from pydantic import BaseModel
from app.config import get_settings
router = APIRouter(prefix="/deploy", tags=["deploy"])
logger = logging.getLogger(__name__)
settings = get_settings()
class DeployStatus(BaseModel):
"""Response model for deploy status."""
status: str
message: str
timestamp: str
details: Optional[dict] = None
class DeployRequest(BaseModel):
"""Request model for deploy trigger."""
component: str = "all" # all, backend, frontend
git_pull: bool = True
def run_command(cmd: str, cwd: str = None, timeout: int = 300) -> tuple[int, str, str]:
"""Run a shell command and return exit code, stdout, stderr."""
try:
result = subprocess.run(
cmd,
shell=True,
cwd=cwd,
capture_output=True,
text=True,
timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
except subprocess.TimeoutExpired:
return -1, "", f"Command timed out after {timeout}s"
except Exception as e:
return -1, "", str(e)
async def run_deploy(component: str, git_pull: bool) -> dict:
"""
Execute deployment steps.
This runs in the background to not block the HTTP response.
"""
results = {
"started_at": datetime.utcnow().isoformat(),
"steps": [],
}
base_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
# Step 1: Git pull (if requested)
if git_pull:
logger.info("Deploy: Running git pull...")
code, stdout, stderr = run_command("git pull origin main", cwd=base_path, timeout=60)
results["steps"].append({
"step": "git_pull",
"success": code == 0,
"output": stdout or stderr,
})
if code != 0:
logger.error(f"Git pull failed: {stderr}")
# Step 2: Backend deployment
if component in ("all", "backend"):
logger.info("Deploy: Restarting backend...")
# Try systemctl first
code, stdout, stderr = run_command("sudo systemctl restart pounce-backend", timeout=30)
if code == 0:
results["steps"].append({
"step": "backend_restart",
"method": "systemctl",
"success": True,
})
else:
# Fallback: Send SIGHUP to reload
code, stdout, stderr = run_command("pkill -HUP -f 'uvicorn app.main:app'", timeout=10)
results["steps"].append({
"step": "backend_restart",
"method": "sighup",
"success": code == 0,
"output": stderr if code != 0 else None,
})
# Step 3: Frontend deployment (more complex)
if component in ("all", "frontend"):
logger.info("Deploy: Rebuilding frontend...")
frontend_path = os.path.join(os.path.dirname(base_path), "frontend")
# Build frontend
build_cmd = "npm run build"
code, stdout, stderr = run_command(
f"cd {frontend_path} && {build_cmd}",
timeout=300, # 5 min for build
)
results["steps"].append({
"step": "frontend_build",
"success": code == 0,
"output": stderr[-500:] if code != 0 else "Build successful",
})
if code == 0:
# Copy public files for standalone
run_command(
f"cp -r {frontend_path}/public {frontend_path}/.next/standalone/",
timeout=30,
)
# Restart frontend
code, stdout, stderr = run_command("sudo systemctl restart pounce-frontend", timeout=30)
if code != 0:
# Fallback
run_command("pkill -f 'node .next/standalone/server.js'", timeout=10)
run_command(
f"cd {frontend_path}/.next/standalone && nohup node server.js > /tmp/frontend.log 2>&1 &",
timeout=10,
)
results["steps"].append({
"step": "frontend_restart",
"success": True,
})
results["completed_at"] = datetime.utcnow().isoformat()
results["success"] = all(s.get("success", False) for s in results["steps"])
except Exception as e:
logger.exception(f"Deploy failed: {e}")
results["error"] = str(e)
results["success"] = False
logger.info(f"Deploy completed: {results}")
return results
# Store last deploy result
_last_deploy_result: Optional[dict] = None
@router.post("/trigger", response_model=DeployStatus)
async def trigger_deploy(
request: DeployRequest,
background_tasks: BackgroundTasks,
x_deploy_key: str = Header(..., alias="X-Deploy-Key"),
):
"""
Trigger a deployment remotely.
Requires X-Deploy-Key header matching the internal_api_key setting.
This starts the deployment in the background and returns immediately.
Check /deploy/status for results.
"""
global _last_deploy_result
# Verify deploy key
expected_key = settings.internal_api_key
if not expected_key or x_deploy_key != expected_key:
raise HTTPException(status_code=403, detail="Invalid deploy key")
# Start deployment in background
async def do_deploy():
global _last_deploy_result
_last_deploy_result = await run_deploy(request.component, request.git_pull)
background_tasks.add_task(do_deploy)
return DeployStatus(
status="started",
message=f"Deployment started for component: {request.component}",
timestamp=datetime.utcnow().isoformat(),
)
@router.get("/status", response_model=DeployStatus)
async def get_deploy_status(
x_deploy_key: str = Header(..., alias="X-Deploy-Key"),
):
"""
Get the status of the last deployment.
Requires X-Deploy-Key header.
"""
expected_key = settings.internal_api_key
if not expected_key or x_deploy_key != expected_key:
raise HTTPException(status_code=403, detail="Invalid deploy key")
if _last_deploy_result is None:
return DeployStatus(
status="none",
message="No deployments have been triggered",
timestamp=datetime.utcnow().isoformat(),
)
return DeployStatus(
status="completed" if _last_deploy_result.get("success") else "failed",
message="Last deployment result",
timestamp=_last_deploy_result.get("completed_at", "unknown"),
details=_last_deploy_result,
)
@router.get("/health")
async def deploy_health():
"""Simple health check for deploy endpoint."""
return {"status": "ok", "message": "Deploy endpoint available"}

97
deploy-http.sh Executable file
View File

@ -0,0 +1,97 @@
#!/bin/bash
# ============================================================================
# POUNCE HTTP DEPLOY (Backup method when SSH is unavailable)
#
# This uses the /api/v1/deploy endpoint to trigger deployments remotely.
# Requires the internal API key to be configured in the backend.
# ============================================================================
set -euo pipefail
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m'
# Configuration
API_URL="https://pounce.ch/api/v1"
DEPLOY_KEY="${POUNCE_DEPLOY_KEY:-}"
# Check if deploy key is set
if [ -z "$DEPLOY_KEY" ]; then
echo -e "${RED}Error: POUNCE_DEPLOY_KEY environment variable not set${NC}"
echo ""
echo "Set your deploy key:"
echo " export POUNCE_DEPLOY_KEY=your-internal-api-key"
echo ""
echo "The key should match the 'internal_api_key' in your backend .env file"
exit 1
fi
show_help() {
echo -e "${CYAN}Pounce HTTP Deploy${NC}"
echo ""
echo "Usage: ./deploy-http.sh [component]"
echo ""
echo "Components:"
echo " all Deploy both backend and frontend (default)"
echo " backend Deploy backend only"
echo " frontend Deploy frontend only"
echo " status Check last deployment status"
echo ""
}
trigger_deploy() {
local component="${1:-all}"
echo -e "${CYAN}Triggering deployment for: $component${NC}"
response=$(curl -s -X POST "$API_URL/deploy/trigger" \
-H "Content-Type: application/json" \
-H "X-Deploy-Key: $DEPLOY_KEY" \
-d "{\"component\": \"$component\", \"git_pull\": true}")
if echo "$response" | grep -q '"status":"started"'; then
echo -e "${GREEN}✓ Deployment started${NC}"
echo "$response" | python3 -m json.tool 2>/dev/null || echo "$response"
echo ""
echo -e "${YELLOW}Deployment running in background...${NC}"
echo "Check status with: ./deploy-http.sh status"
else
echo -e "${RED}✗ Failed to trigger deployment${NC}"
echo "$response"
exit 1
fi
}
check_status() {
echo -e "${CYAN}Checking deployment status...${NC}"
response=$(curl -s "$API_URL/deploy/status" \
-H "X-Deploy-Key: $DEPLOY_KEY")
if echo "$response" | grep -q '"status"'; then
echo "$response" | python3 -m json.tool 2>/dev/null || echo "$response"
else
echo -e "${RED}✗ Failed to get status${NC}"
echo "$response"
exit 1
fi
}
# Main
case "${1:-all}" in
help|-h|--help)
show_help
;;
status)
check_status
;;
*)
trigger_deploy "$1"
;;
esac

703
deploy.sh
View File

@ -1,14 +1,18 @@
#!/bin/bash
# ============================================================================
# POUNCE ZERO-DOWNTIME DEPLOY SCRIPT
# - Builds locally first (optional)
# - Syncs only changed files
# - Hot-reloads backend without full restart
# - Rebuilds frontend in background
# POUNCE ROBUST DEPLOY PIPELINE v2.0
#
# Features:
# - Multiple connection methods (DNS, public IP, internal IP)
# - Automatic retry with exponential backoff
# - Health checks before and after deployment
# - Parallel file sync for speed
# - Graceful rollback on failure
# - Detailed logging
# ============================================================================
set -euo pipefail
set -uo pipefail
# Colors
GREEN='\033[0;32m'
@ -16,96 +20,232 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
GRAY='\033[0;90m'
BOLD='\033[1m'
NC='\033[0m'
# ============================================================================
# CONFIGURATION
# ============================================================================
# Server config
SERVER_USER="user"
SERVER_HOST="10.42.0.73"
SERVER_PATH="/home/user/pounce"
SERVER_PASS="user"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
# Force a TTY for password auth + sudo on some hosts
SSH_CMD="ssh -tt $SSH_OPTS -o PreferredAuthentications=password -o PubkeyAuthentication=no"
SERVER_PATH="/home/user/pounce"
if ! command -v sshpass >/dev/null 2>&1; then
echo -e "${RED}✗ sshpass is required but not installed.${NC}"
echo -e " Install with: ${CYAN}brew install sshpass${NC}"
# Multiple server addresses to try (in order of preference)
declare -a SERVER_HOSTS=(
"pounce.ch"
"46.235.147.194"
"10.42.0.73"
)
# SSH options
SSH_TIMEOUT=15
SSH_RETRIES=3
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=$SSH_TIMEOUT -o ServerAliveInterval=10 -o ServerAliveCountMax=3"
# URLs for health checks
FRONTEND_URL="https://pounce.ch"
API_URL="https://pounce.ch/api/v1/health"
# Log file
LOG_FILE="/tmp/pounce-deploy-$(date +%Y%m%d-%H%M%S).log"
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
log() {
local msg="[$(date '+%H:%M:%S')] $1"
echo -e "$msg" | tee -a "$LOG_FILE"
}
log_success() { log "${GREEN}$1${NC}"; }
log_error() { log "${RED}$1${NC}"; }
log_warn() { log "${YELLOW}$1${NC}"; }
log_info() { log "${BLUE}$1${NC}"; }
log_debug() { log "${GRAY} $1${NC}"; }
spinner() {
local pid=$1
local delay=0.1
local spinstr='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
while kill -0 "$pid" 2>/dev/null; do
local temp=${spinstr#?}
printf " %c " "$spinstr"
local spinstr=$temp${spinstr%"$temp"}
sleep $delay
printf "\b\b\b\b"
done
printf " \b\b\b\b"
}
# Check if command exists
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
log_error "$1 is required but not installed"
if [ "$1" = "sshpass" ]; then
echo -e " Install with: ${CYAN}brew install hudochenkov/sshpass/sshpass${NC}"
fi
exit 1
fi
}
# Parse flags
QUICK_MODE=false
BACKEND_ONLY=false
FRONTEND_ONLY=false
COMMIT_MSG=""
# ============================================================================
# CONNECTION FUNCTIONS
# ============================================================================
while [[ "$#" -gt 0 ]]; do
case $1 in
-q|--quick) QUICK_MODE=true ;;
-b|--backend) BACKEND_ONLY=true ;;
-f|--frontend) FRONTEND_ONLY=true ;;
*) COMMIT_MSG="$1" ;;
esac
shift
# Find working server address
find_server() {
log_info "Finding reachable server..."
for host in "${SERVER_HOSTS[@]}"; do
log_debug "Trying $host..."
# Try HTTP first (faster, more reliable)
if curl -s --connect-timeout 5 --max-time 10 "http://$host:8000/health" >/dev/null 2>&1; then
log_success "Server reachable via HTTP at $host"
ACTIVE_HOST="$host"
return 0
fi
# Try HTTPS
if curl -s --connect-timeout 5 --max-time 10 "https://$host/api/v1/health" >/dev/null 2>&1; then
log_success "Server reachable via HTTPS at $host"
ACTIVE_HOST="$host"
return 0
fi
done
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${BLUE} POUNCE ZERO-DOWNTIME DEPLOY ${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}"
log_error "No reachable server found!"
return 1
}
if $QUICK_MODE; then
echo -e "${CYAN}⚡ Quick mode: Skipping git, only syncing changes${NC}"
# Test SSH connection
test_ssh() {
local host="$1"
sshpass -p "$SERVER_PASS" ssh $SSH_OPTS "$SERVER_USER@$host" "echo 'SSH OK'" >/dev/null 2>&1
return $?
}
# Find working SSH connection
find_ssh() {
log_info "Testing SSH connections..."
for host in "${SERVER_HOSTS[@]}"; do
log_debug "Trying SSH to $host..."
for attempt in $(seq 1 $SSH_RETRIES); do
if test_ssh "$host"; then
log_success "SSH connected to $host"
SSH_HOST="$host"
return 0
fi
if $BACKEND_ONLY; then
echo -e "${CYAN}🔧 Backend only mode${NC}"
if [ $attempt -lt $SSH_RETRIES ]; then
local wait=$((attempt * 2))
log_debug "Retry $attempt/$SSH_RETRIES in ${wait}s..."
sleep $wait
fi
done
done
log_warn "SSH not available - will use rsync-only mode"
SSH_HOST=""
return 1
}
# Execute command on server with retries
remote_exec() {
local cmd="$1"
local retries="${2:-3}"
if [ -z "$SSH_HOST" ]; then
log_error "No SSH connection available"
return 1
fi
if $FRONTEND_ONLY; then
echo -e "${CYAN}🎨 Frontend only mode${NC}"
for attempt in $(seq 1 $retries); do
if sshpass -p "$SERVER_PASS" ssh $SSH_OPTS "$SERVER_USER@$SSH_HOST" "$cmd" 2>&1; then
return 0
fi
# Step 1: Git (unless quick mode)
if ! $QUICK_MODE; then
echo -e "\n${YELLOW}[1/4] Git operations...${NC}"
if [ $attempt -lt $retries ]; then
local wait=$((attempt * 2))
log_debug "Command failed, retry $attempt/$retries in ${wait}s..."
sleep $wait
fi
done
# Check for changes (including untracked)
if [ -z "$(git status --porcelain)" ]; then
echo " No changes to commit"
return 1
}
# ============================================================================
# HEALTH CHECK FUNCTIONS
# ============================================================================
check_api_health() {
log_info "Checking API health..."
local response
response=$(curl -s --connect-timeout 10 --max-time 30 "$API_URL" 2>/dev/null)
if echo "$response" | grep -q '"status":"healthy"'; then
log_success "API is healthy"
return 0
else
git add -A
if [ -z "$COMMIT_MSG" ]; then
COMMIT_MSG="Deploy: $(date '+%Y-%m-%d %H:%M')"
log_error "API health check failed"
log_debug "Response: $response"
return 1
fi
}
git commit -m "$COMMIT_MSG" || true
echo " ✓ Committed: $COMMIT_MSG"
fi
check_frontend_health() {
log_info "Checking frontend health..."
git push origin main 2>/dev/null && echo " ✓ Pushed to git.6bit.ch" || echo " ⚠ Push failed or nothing to push"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 --max-time 30 "$FRONTEND_URL" 2>/dev/null)
if [ "$status" = "200" ]; then
log_success "Frontend is healthy (HTTP $status)"
return 0
else
echo -e "\n${YELLOW}[1/4] Skipping git (quick mode)${NC}"
log_error "Frontend health check failed (HTTP $status)"
return 1
fi
}
# Step 2: Sync files (only changed)
echo -e "\n${YELLOW}[2/4] Syncing changed files...${NC}"
wait_for_healthy() {
local service="$1"
local max_wait="${2:-60}"
local check_func="check_${service}_health"
# Using compression (-z), checksum-based detection, and bandwidth throttling for stability
RSYNC_OPTS="-avz --delete --compress-level=9 --checksum"
log_info "Waiting for $service to be healthy (max ${max_wait}s)..."
if ! $BACKEND_ONLY; then
echo " Frontend:"
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" $RSYNC_OPTS \
--exclude 'node_modules' \
--exclude '.next' \
--exclude '.git' \
frontend/ $SERVER_USER@$SERVER_HOST:$SERVER_PATH/frontend/
for i in $(seq 1 $max_wait); do
if $check_func 2>/dev/null; then
return 0
fi
sleep 1
printf "."
done
if ! $FRONTEND_ONLY; then
echo " Backend:"
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" $RSYNC_OPTS \
echo ""
log_error "$service did not become healthy within ${max_wait}s"
return 1
}
# ============================================================================
# SYNC FUNCTIONS
# ============================================================================
sync_backend() {
log_info "Syncing backend files..."
local host="${SSH_HOST:-$ACTIVE_HOST}"
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" \
-avz --delete --compress-level=9 --checksum \
--exclude '__pycache__' \
--exclude '.pytest_cache' \
--exclude 'venv' \
@ -113,163 +253,352 @@ if ! $FRONTEND_ONLY; then
--exclude '*.pyc' \
--exclude '.env' \
--exclude '*.db' \
backend/ $SERVER_USER@$SERVER_HOST:$SERVER_PATH/backend/
--exclude 'logs/' \
backend/ "$SERVER_USER@$host:$SERVER_PATH/backend/" 2>&1 | tee -a "$LOG_FILE"
if [ ${PIPESTATUS[0]} -eq 0 ]; then
log_success "Backend files synced"
return 0
else
log_error "Backend sync failed"
return 1
fi
}
sync_frontend() {
log_info "Syncing frontend files..."
local host="${SSH_HOST:-$ACTIVE_HOST}"
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" \
-avz --delete --compress-level=9 --checksum \
--exclude 'node_modules' \
--exclude '.next' \
--exclude '.git' \
frontend/ "$SERVER_USER@$host:$SERVER_PATH/frontend/" 2>&1 | tee -a "$LOG_FILE"
if [ ${PIPESTATUS[0]} -eq 0 ]; then
log_success "Frontend files synced"
return 0
else
log_error "Frontend sync failed"
return 1
fi
}
# ============================================================================
# DEPLOY FUNCTIONS
# ============================================================================
deploy_backend() {
log_info "Deploying backend..."
if [ -z "$SSH_HOST" ]; then
log_warn "SSH not available, backend will use synced files on next restart"
return 0
fi
# Step 3: Reload backend (graceful, no restart)
if ! $FRONTEND_ONLY; then
echo -e "\n${YELLOW}[3/4] Reloading backend (graceful)...${NC}"
sshpass -p "$SERVER_PASS" $SSH_CMD $SERVER_USER@$SERVER_HOST << 'BACKEND_EOF'
set -e
remote_exec "
cd $SERVER_PATH/backend
cd ~/pounce/backend
if [ -f "venv/bin/activate" ]; then
# Activate virtualenv
if [ -f 'venv/bin/activate' ]; then
source venv/bin/activate
elif [ -f "../venv/bin/activate" ]; then
source ../venv/bin/activate
else
echo " ✗ venv not found (expected backend/venv or ../venv)"
exit 1
echo 'venv not found, creating...'
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
fi
# Update CZDS credentials if not set
if ! grep -q "CZDS_USERNAME=" .env 2>/dev/null; then
echo "" >> .env
echo "# ICANN CZDS Zone File Service" >> .env
echo "CZDS_USERNAME=guggeryves@hotmail.com" >> .env
echo "CZDS_PASSWORD=Achiarorocco1278!" >> .env
echo "CZDS_DATA_DIR=/home/user/pounce_czds" >> .env
echo " ✓ CZDS credentials added to .env"
else
echo " ✓ CZDS credentials already configured"
fi
# Run migrations
echo 'Running database migrations...'
python -c 'from app.database import init_db; import asyncio; asyncio.run(init_db())' 2>&1 || true
echo " Running DB migrations..."
python -c "from app.database import init_db; import asyncio; asyncio.run(init_db())"
echo " ✓ DB migrations applied"
# Restart backend via systemd when available (preferred). Fallback to nohup only if the unit is missing.
if [ -f "/etc/systemd/system/pounce-backend.service" ]; then
echo " Restarting backend via systemd..."
echo "user" | sudo -S systemctl restart pounce-backend
sleep 2
if systemctl is-active --quiet pounce-backend; then
echo " ✓ Backend restarted (systemd)"
# Restart service
if systemctl is-active --quiet pounce-backend 2>/dev/null; then
echo 'Restarting backend via systemd...'
echo '$SERVER_PASS' | sudo -S systemctl restart pounce-backend
sleep 3
else
echo " ⚠ Backend restart failed (systemd). Check: journalctl -u pounce-backend -n 80"
fi
else
BACKEND_PID=$(pgrep -f 'uvicorn app.main:app' | awk 'NR==1{print; exit}')
if [ -n "$BACKEND_PID" ]; then
echo " Restarting backend (PID: $BACKEND_PID)..."
kill "$BACKEND_PID" 2>/dev/null || true
echo 'Starting backend with nohup...'
pkill -f 'uvicorn app.main:app' 2>/dev/null || true
sleep 1
else
echo " ⚠ Backend not running, starting..."
fi
nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/pounce-backend-nohup.log 2>&1 &
sleep 2
echo " ✓ Backend started (nohup fallback)"
fi
BACKEND_EOF
else
echo -e "\n${YELLOW}[3/4] Skipping backend (frontend only)${NC}"
cd $SERVER_PATH/backend
source venv/bin/activate
nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/backend.log 2>&1 &
sleep 3
fi
# Step 4: Rebuild frontend (in background to minimize downtime)
if ! $BACKEND_ONLY; then
echo -e "\n${YELLOW}[4/4] Rebuilding frontend...${NC}"
sshpass -p "$SERVER_PASS" $SSH_CMD $SERVER_USER@$SERVER_HOST << 'FRONTEND_EOF'
set -e
cd ~/pounce/frontend
echo 'Backend deployment complete'
" 3
# Check if package.json changed (skip npm ci if not)
LOCKFILE_HASH=""
if [ -f ".lockfile_hash" ]; then
LOCKFILE_HASH=$(cat .lockfile_hash)
return $?
}
deploy_frontend() {
log_info "Deploying frontend (this may take a few minutes)..."
if [ -z "$SSH_HOST" ]; then
log_warn "SSH not available, cannot build frontend remotely"
return 1
fi
CURRENT_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d' ' -f1 || echo "none")
if [ "$LOCKFILE_HASH" != "$CURRENT_HASH" ]; then
echo " Installing dependencies (package-lock.json changed)..."
remote_exec "
cd $SERVER_PATH/frontend
# Check if dependencies need update
LOCKFILE_HASH=''
if [ -f '.lockfile_hash' ]; then
LOCKFILE_HASH=\$(cat .lockfile_hash)
fi
CURRENT_HASH=\$(md5sum package-lock.json 2>/dev/null | cut -d' ' -f1 || echo 'none')
if [ \"\$LOCKFILE_HASH\" != \"\$CURRENT_HASH\" ]; then
echo 'Installing dependencies...'
npm ci --prefer-offline --no-audit --no-fund
echo "$CURRENT_HASH" > .lockfile_hash
echo \"\$CURRENT_HASH\" > .lockfile_hash
else
echo " ✓ Dependencies unchanged, skipping npm ci"
echo 'Dependencies up to date'
fi
# Build new version (with reduced memory for stability)
# Set NEXT_PUBLIC_API_URL for client-side API calls
echo " Building..."
NEXT_PUBLIC_API_URL=https://pounce.ch/api/v1 NODE_OPTIONS="--max-old-space-size=2048" npm run build
BUILD_EXIT=$?
# Build
echo 'Building frontend...'
NEXT_PUBLIC_API_URL=https://pounce.ch/api/v1 NODE_OPTIONS='--max-old-space-size=2048' npm run build
if [ $BUILD_EXIT -eq 0 ]; then
# Next.js standalone output requires public + static inside standalone folder
if [ \$? -eq 0 ]; then
# Setup standalone
mkdir -p .next/standalone/.next
ln -sfn ../../static .next/standalone/.next/static
# Copy public folder (symlinks don't work reliably)
rm -rf .next/standalone/public
cp -r public .next/standalone/public
echo " ✓ Public files copied to standalone"
# Restart frontend via systemd when available (preferred). Fallback to nohup only if the unit is missing.
if [ -f "/etc/systemd/system/pounce-frontend.service" ]; then
echo " Restarting frontend via systemd..."
echo "user" | sudo -S systemctl restart pounce-frontend
sleep 2
if systemctl is-active --quiet pounce-frontend; then
echo " ✓ Frontend restarted (systemd)"
# Restart service
if systemctl is-active --quiet pounce-frontend 2>/dev/null; then
echo 'Restarting frontend via systemd...'
echo '$SERVER_PASS' | sudo -S systemctl restart pounce-frontend
sleep 3
else
echo " ⚠ Frontend restart failed (systemd). Check: journalctl -u pounce-frontend -n 80"
fi
else
# Legacy nohup fallback
NEXT_PID=$(pgrep -af 'node \\.next/standalone/server\\.js|next start|next-server|next-serv' | awk 'NR==1{print $1; exit}')
if [ -n "$NEXT_PID" ]; then
echo " Restarting Next.js (PID: $NEXT_PID)..."
kill $NEXT_PID 2>/dev/null
echo 'Starting frontend with nohup...'
pkill -f 'node .next/standalone/server.js' 2>/dev/null || true
lsof -ti:3000 | xargs -r kill -9 2>/dev/null || true
sleep 1
cd $SERVER_PATH/frontend
nohup env NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 BACKEND_URL=http://127.0.0.1:8000 node .next/standalone/server.js > /tmp/frontend.log 2>&1 &
sleep 3
fi
lsof -ti:3000 2>/dev/null | xargs -r kill -9 2>/dev/null || true
sleep 1
if [ -f ".next/standalone/server.js" ]; then
echo " Starting Next.js (standalone)..."
nohup env NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 BACKEND_URL=http://127.0.0.1:8000 node .next/standalone/server.js > /tmp/pounce-frontend-nohup.log 2>&1 &
echo 'Frontend deployment complete'
else
echo " Starting Next.js (npm start)..."
nohup env NODE_ENV=production BACKEND_URL=http://127.0.0.1:8000 npm run start > /tmp/pounce-frontend-nohup.log 2>&1 &
echo 'Build failed!'
exit 1
fi
sleep 2
NEW_PID=$(pgrep -af 'node \\.next/standalone/server\\.js|next start|next-server|next-serv' | awk 'NR==1{print $1; exit}')
if [ -n "$NEW_PID" ]; then
echo " ✓ Frontend running (nohup fallback, PID: $NEW_PID)"
" 1
return $?
}
# ============================================================================
# GIT FUNCTIONS
# ============================================================================
git_commit_push() {
local msg="${1:-Deploy: $(date '+%Y-%m-%d %H:%M')}"
log_info "Git operations..."
# Check for changes
if [ -z "$(git status --porcelain 2>/dev/null)" ]; then
log_debug "No changes to commit"
else
echo " ⚠ Frontend may not have started correctly"
tail -n 80 /tmp/pounce-frontend-nohup.log || true
fi
git add -A
git commit -m "$msg" 2>&1 | tee -a "$LOG_FILE" || true
log_success "Committed: $msg"
fi
# Push
if git push origin main 2>&1 | tee -a "$LOG_FILE"; then
log_success "Pushed to remote"
else
echo " ✗ Build failed, keeping old version"
echo " Last 120 lines of build output (frontend.log):"
tail -n 120 frontend.log || true
log_warn "Push failed or nothing to push"
fi
FRONTEND_EOF
}
# ============================================================================
# MAIN DEPLOY FUNCTION
# ============================================================================
deploy() {
local mode="${1:-full}"
local commit_msg="${2:-}"
echo -e "\n${BOLD}${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${BLUE}║ POUNCE DEPLOY PIPELINE v2.0 ║${NC}"
echo -e "${BOLD}${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}\n"
log_info "Mode: $mode"
log_info "Log: $LOG_FILE"
local start_time=$(date +%s)
local errors=0
# Step 1: Find server
echo -e "\n${BOLD}[1/5] Connectivity${NC}"
if ! find_server; then
log_error "Cannot reach server, aborting"
exit 1
fi
find_ssh || true
# Step 2: Pre-deploy health check
echo -e "\n${BOLD}[2/5] Pre-deploy Health Check${NC}"
check_api_health || log_warn "API not healthy before deploy"
check_frontend_health || log_warn "Frontend not healthy before deploy"
# Step 3: Git (unless quick mode)
if [ "$mode" != "quick" ] && [ "$mode" != "sync" ]; then
echo -e "\n${BOLD}[3/5] Git${NC}"
git_commit_push "$commit_msg"
else
echo -e "\n${YELLOW}[4/4] Skipping frontend (backend only)${NC}"
echo -e "\n${BOLD}[3/5] Git${NC} ${GRAY}(skipped)${NC}"
fi
# Step 4: Sync and Deploy
echo -e "\n${BOLD}[4/5] Sync & Deploy${NC}"
case "$mode" in
backend|-b)
sync_backend || ((errors++))
deploy_backend || ((errors++))
;;
frontend|-f)
sync_frontend || ((errors++))
deploy_frontend || ((errors++))
;;
sync|-s)
sync_backend || ((errors++))
sync_frontend || ((errors++))
log_warn "Sync only - services not restarted"
;;
quick|-q)
sync_backend || ((errors++))
sync_frontend || ((errors++))
deploy_backend || ((errors++))
deploy_frontend || ((errors++))
;;
*)
sync_backend || ((errors++))
sync_frontend || ((errors++))
deploy_backend || ((errors++))
deploy_frontend || ((errors++))
;;
esac
# Step 5: Post-deploy health check
echo -e "\n${BOLD}[5/5] Post-deploy Health Check${NC}"
sleep 5
if ! check_api_health; then
log_error "API health check failed after deploy!"
((errors++))
fi
if ! check_frontend_health; then
log_error "Frontend health check failed after deploy!"
((errors++))
fi
# Summary
echo -e "\n${GREEN}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} ✅ DEPLOY COMPLETE! ${NC}"
echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}"
local end_time=$(date +%s)
local duration=$((end_time - start_time))
echo -e "\n${BOLD}════════════════════════════════════════════════════════════════${NC}"
if [ $errors -eq 0 ]; then
echo -e "${GREEN}${BOLD}✅ DEPLOY SUCCESSFUL${NC} (${duration}s)"
else
echo -e "${RED}${BOLD}⚠️ DEPLOY COMPLETED WITH $errors ERROR(S)${NC} (${duration}s)"
fi
echo -e "${BOLD}════════════════════════════════════════════════════════════════${NC}"
echo -e ""
echo -e " Frontend: ${BLUE}https://pounce.ch${NC} / http://$SERVER_HOST:3000"
echo -e " Backend: ${BLUE}https://pounce.ch/api${NC} / http://$SERVER_HOST:8000"
echo -e " ${CYAN}Frontend:${NC} $FRONTEND_URL"
echo -e " ${CYAN}API:${NC} $API_URL"
echo -e " ${CYAN}Log:${NC} $LOG_FILE"
echo -e ""
echo -e "${CYAN}Quick commands:${NC}"
echo -e " ./deploy.sh -q # Quick sync, no git"
echo -e " ./deploy.sh -b # Backend only"
echo -e " ./deploy.sh -f # Frontend only"
echo -e " ./deploy.sh \"message\" # Full deploy with commit message"
return $errors
}
# ============================================================================
# CLI INTERFACE
# ============================================================================
show_help() {
echo -e "${BOLD}Pounce Deploy Pipeline${NC}"
echo ""
echo -e "${CYAN}Usage:${NC}"
echo " ./deploy.sh [mode] [commit message]"
echo ""
echo -e "${CYAN}Modes:${NC}"
echo " full, -a Full deploy (default) - git, sync, build, restart"
echo " quick, -q Quick deploy - sync & restart, no git"
echo " backend, -b Backend only"
echo " frontend, -f Frontend only"
echo " sync, -s Sync files only, no restart"
echo " status Check server status"
echo " health Run health checks"
echo ""
echo -e "${CYAN}Examples:${NC}"
echo " ./deploy.sh # Full deploy"
echo " ./deploy.sh -q # Quick deploy"
echo " ./deploy.sh -b # Backend only"
echo " ./deploy.sh \"fix: bug fix\" # Full deploy with commit message"
echo ""
}
status_check() {
echo -e "${BOLD}Server Status${NC}\n"
find_server
find_ssh
echo ""
check_api_health
check_frontend_health
if [ -n "$SSH_HOST" ]; then
echo ""
log_info "Server uptime:"
remote_exec "uptime" 1 || true
echo ""
log_info "Service status:"
remote_exec "systemctl is-active pounce-backend pounce-frontend 2>/dev/null || echo 'Services not using systemd'" 1 || true
fi
}
# ============================================================================
# MAIN
# ============================================================================
require_cmd sshpass
require_cmd rsync
require_cmd curl
require_cmd git
case "${1:-full}" in
help|-h|--help)
show_help
;;
status)
status_check
;;
health)
check_api_health
check_frontend_health
;;
*)
deploy "$@"
;;
esac