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 import router as llm_router
from app.api.llm_naming import router as llm_naming_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.llm_vision import router as llm_vision_router
from app.api.deploy import router as deploy_router
api_router = APIRouter() api_router = APIRouter()
@ -81,3 +82,6 @@ api_router.include_router(blog_router, prefix="/blog", tags=["Blog"])
# Admin endpoints # Admin endpoints
api_router.include_router(admin_router, prefix="/admin", tags=["Admin"]) 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

739
deploy.sh
View File

@ -1,14 +1,18 @@
#!/bin/bash #!/bin/bash
# ============================================================================ # ============================================================================
# POUNCE ZERO-DOWNTIME DEPLOY SCRIPT # POUNCE ROBUST DEPLOY PIPELINE v2.0
# - Builds locally first (optional) #
# - Syncs only changed files # Features:
# - Hot-reloads backend without full restart # - Multiple connection methods (DNS, public IP, internal IP)
# - Rebuilds frontend in background # - 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 # Colors
GREEN='\033[0;32m' GREEN='\033[0;32m'
@ -16,96 +20,232 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
RED='\033[0;31m' RED='\033[0;31m'
CYAN='\033[0;36m' 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_USER="user"
SERVER_HOST="10.42.0.73"
SERVER_PATH="/home/user/pounce"
SERVER_PASS="user" SERVER_PASS="user"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" SERVER_PATH="/home/user/pounce"
# Force a TTY for password auth + sudo on some hosts
SSH_CMD="ssh -tt $SSH_OPTS -o PreferredAuthentications=password -o PubkeyAuthentication=no"
if ! command -v sshpass >/dev/null 2>&1; then # Multiple server addresses to try (in order of preference)
echo -e "${RED}✗ sshpass is required but not installed.${NC}" declare -a SERVER_HOSTS=(
echo -e " Install with: ${CYAN}brew install sshpass${NC}" "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 exit 1
fi fi
}
# Parse flags # ============================================================================
QUICK_MODE=false # CONNECTION FUNCTIONS
BACKEND_ONLY=false # ============================================================================
FRONTEND_ONLY=false
COMMIT_MSG=""
while [[ "$#" -gt 0 ]]; do # Find working server address
case $1 in find_server() {
-q|--quick) QUICK_MODE=true ;; log_info "Finding reachable server..."
-b|--backend) BACKEND_ONLY=true ;;
-f|--frontend) FRONTEND_ONLY=true ;;
*) COMMIT_MSG="$1" ;;
esac
shift
done
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" for host in "${SERVER_HOSTS[@]}"; do
echo -e "${BLUE} POUNCE ZERO-DOWNTIME DEPLOY ${NC}" log_debug "Trying $host..."
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}"
if $QUICK_MODE; then # Try HTTP first (faster, more reliable)
echo -e "${CYAN}⚡ Quick mode: Skipping git, only syncing changes${NC}" if curl -s --connect-timeout 5 --max-time 10 "http://$host:8000/health" >/dev/null 2>&1; then
fi log_success "Server reachable via HTTP at $host"
ACTIVE_HOST="$host"
return 0
fi
if $BACKEND_ONLY; then # Try HTTPS
echo -e "${CYAN}🔧 Backend only mode${NC}" if curl -s --connect-timeout 5 --max-time 10 "https://$host/api/v1/health" >/dev/null 2>&1; then
fi log_success "Server reachable via HTTPS at $host"
ACTIVE_HOST="$host"
return 0
fi
done
if $FRONTEND_ONLY; then log_error "No reachable server found!"
echo -e "${CYAN}🎨 Frontend only mode${NC}" return 1
fi }
# Step 1: Git (unless quick mode) # Test SSH connection
if ! $QUICK_MODE; then test_ssh() {
echo -e "\n${YELLOW}[1/4] Git operations...${NC}" local host="$1"
sshpass -p "$SERVER_PASS" ssh $SSH_OPTS "$SERVER_USER@$host" "echo 'SSH OK'" >/dev/null 2>&1
return $?
}
# Check for changes (including untracked) # Find working SSH connection
if [ -z "$(git status --porcelain)" ]; then find_ssh() {
echo " No changes to commit" 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 [ $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
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
if [ $attempt -lt $retries ]; then
local wait=$((attempt * 2))
log_debug "Command failed, retry $attempt/$retries in ${wait}s..."
sleep $wait
fi
done
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 else
git add -A log_error "API health check failed"
log_debug "Response: $response"
if [ -z "$COMMIT_MSG" ]; then return 1
COMMIT_MSG="Deploy: $(date '+%Y-%m-%d %H:%M')"
fi fi
}
git commit -m "$COMMIT_MSG" || true check_frontend_health() {
echo " ✓ Committed: $COMMIT_MSG" log_info "Checking frontend health..."
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
log_error "Frontend health check failed (HTTP $status)"
return 1
fi fi
}
git push origin main 2>/dev/null && echo " ✓ Pushed to git.6bit.ch" || echo " ⚠ Push failed or nothing to push" wait_for_healthy() {
else local service="$1"
echo -e "\n${YELLOW}[1/4] Skipping git (quick mode)${NC}" local max_wait="${2:-60}"
fi local check_func="check_${service}_health"
# Step 2: Sync files (only changed) log_info "Waiting for $service to be healthy (max ${max_wait}s)..."
echo -e "\n${YELLOW}[2/4] Syncing changed files...${NC}"
# Using compression (-z), checksum-based detection, and bandwidth throttling for stability for i in $(seq 1 $max_wait); do
RSYNC_OPTS="-avz --delete --compress-level=9 --checksum" if $check_func 2>/dev/null; then
return 0
fi
sleep 1
printf "."
done
if ! $BACKEND_ONLY; then echo ""
echo " Frontend:" log_error "$service did not become healthy within ${max_wait}s"
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" $RSYNC_OPTS \ return 1
--exclude 'node_modules' \ }
--exclude '.next' \
--exclude '.git' \
frontend/ $SERVER_USER@$SERVER_HOST:$SERVER_PATH/frontend/
fi
if ! $FRONTEND_ONLY; then # ============================================================================
echo " Backend:" # SYNC FUNCTIONS
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" $RSYNC_OPTS \ # ============================================================================
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 '__pycache__' \
--exclude '.pytest_cache' \ --exclude '.pytest_cache' \
--exclude 'venv' \ --exclude 'venv' \
@ -113,163 +253,352 @@ if ! $FRONTEND_ONLY; then
--exclude '*.pyc' \ --exclude '*.pyc' \
--exclude '.env' \ --exclude '.env' \
--exclude '*.db' \ --exclude '*.db' \
backend/ $SERVER_USER@$SERVER_HOST:$SERVER_PATH/backend/ --exclude 'logs/' \
fi backend/ "$SERVER_USER@$host:$SERVER_PATH/backend/" 2>&1 | tee -a "$LOG_FILE"
# Step 3: Reload backend (graceful, no restart) if [ ${PIPESTATUS[0]} -eq 0 ]; then
if ! $FRONTEND_ONLY; then log_success "Backend files synced"
echo -e "\n${YELLOW}[3/4] Reloading backend (graceful)...${NC}" return 0
sshpass -p "$SERVER_PASS" $SSH_CMD $SERVER_USER@$SERVER_HOST << 'BACKEND_EOF' else
set -e log_error "Backend sync failed"
return 1
fi
}
cd ~/pounce/backend sync_frontend() {
if [ -f "venv/bin/activate" ]; then 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
remote_exec "
cd $SERVER_PATH/backend
# Activate virtualenv
if [ -f 'venv/bin/activate' ]; then
source venv/bin/activate source venv/bin/activate
elif [ -f "../venv/bin/activate" ]; then
source ../venv/bin/activate
else else
echo " ✗ venv not found (expected backend/venv or ../venv)" echo 'venv not found, creating...'
exit 1 python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
fi fi
# Update CZDS credentials if not set # Run migrations
if ! grep -q "CZDS_USERNAME=" .env 2>/dev/null; then echo 'Running database migrations...'
echo "" >> .env python -c 'from app.database import init_db; import asyncio; asyncio.run(init_db())' 2>&1 || true
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
echo " Running DB migrations..." # Restart service
python -c "from app.database import init_db; import asyncio; asyncio.run(init_db())" if systemctl is-active --quiet pounce-backend 2>/dev/null; then
echo " ✓ DB migrations applied" echo 'Restarting backend via systemd...'
echo '$SERVER_PASS' | sudo -S systemctl restart pounce-backend
# Restart backend via systemd when available (preferred). Fallback to nohup only if the unit is missing. sleep 3
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)"
else else
echo " ⚠ Backend restart failed (systemd). Check: journalctl -u pounce-backend -n 80" echo 'Starting backend with nohup...'
fi pkill -f 'uvicorn app.main:app' 2>/dev/null || true
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
sleep 1 sleep 1
else cd $SERVER_PATH/backend
echo " ⚠ Backend not running, starting..." source venv/bin/activate
nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/backend.log 2>&1 &
sleep 3
fi fi
nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/pounce-backend-nohup.log 2>&1 &
sleep 2 echo 'Backend deployment complete'
echo " ✓ Backend started (nohup fallback)" " 3
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 fi
BACKEND_EOF
else
echo -e "\n${YELLOW}[3/4] Skipping backend (frontend only)${NC}"
fi
# Step 4: Rebuild frontend (in background to minimize downtime) remote_exec "
if ! $BACKEND_ONLY; then cd $SERVER_PATH/frontend
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
# Check if package.json changed (skip npm ci if not) # Check if dependencies need update
LOCKFILE_HASH="" LOCKFILE_HASH=''
if [ -f ".lockfile_hash" ]; then if [ -f '.lockfile_hash' ]; then
LOCKFILE_HASH=$(cat .lockfile_hash) LOCKFILE_HASH=\$(cat .lockfile_hash)
fi fi
CURRENT_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d' ' -f1 || echo "none") CURRENT_HASH=\$(md5sum package-lock.json 2>/dev/null | cut -d' ' -f1 || echo 'none')
if [ "$LOCKFILE_HASH" != "$CURRENT_HASH" ]; then if [ \"\$LOCKFILE_HASH\" != \"\$CURRENT_HASH\" ]; then
echo " Installing dependencies (package-lock.json changed)..." echo 'Installing dependencies...'
npm ci --prefer-offline --no-audit --no-fund npm ci --prefer-offline --no-audit --no-fund
echo "$CURRENT_HASH" > .lockfile_hash echo \"\$CURRENT_HASH\" > .lockfile_hash
else else
echo " ✓ Dependencies unchanged, skipping npm ci" echo 'Dependencies up to date'
fi fi
# Build new version (with reduced memory for stability) # Build
# Set NEXT_PUBLIC_API_URL for client-side API calls echo 'Building frontend...'
echo " Building..." NEXT_PUBLIC_API_URL=https://pounce.ch/api/v1 NODE_OPTIONS='--max-old-space-size=2048' npm run build
NEXT_PUBLIC_API_URL=https://pounce.ch/api/v1 NODE_OPTIONS="--max-old-space-size=2048" npm run build
BUILD_EXIT=$?
if [ $BUILD_EXIT -eq 0 ]; then if [ \$? -eq 0 ]; then
# Next.js standalone output requires public + static inside standalone folder # Setup standalone
mkdir -p .next/standalone/.next mkdir -p .next/standalone/.next
ln -sfn ../../static .next/standalone/.next/static ln -sfn ../../static .next/standalone/.next/static
# Copy public folder (symlinks don't work reliably)
rm -rf .next/standalone/public rm -rf .next/standalone/public
cp -r public .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. # Restart service
if [ -f "/etc/systemd/system/pounce-frontend.service" ]; then if systemctl is-active --quiet pounce-frontend 2>/dev/null; then
echo " Restarting frontend via systemd..." echo 'Restarting frontend via systemd...'
echo "user" | sudo -S systemctl restart pounce-frontend echo '$SERVER_PASS' | sudo -S systemctl restart pounce-frontend
sleep 2 sleep 3
if systemctl is-active --quiet pounce-frontend; then
echo " ✓ Frontend restarted (systemd)"
else else
echo " ⚠ Frontend restart failed (systemd). Check: journalctl -u pounce-frontend -n 80" echo 'Starting frontend with nohup...'
fi pkill -f 'node .next/standalone/server.js' 2>/dev/null || true
else lsof -ti:3000 | xargs -r kill -9 2>/dev/null || true
# 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
sleep 1 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 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 &
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 &
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)"
else
echo " ⚠ Frontend may not have started correctly"
tail -n 80 /tmp/pounce-frontend-nohup.log || true
fi
fi
else
echo " ✗ Build failed, keeping old version"
echo " Last 120 lines of build output (frontend.log):"
tail -n 120 frontend.log || true
fi
FRONTEND_EOF
else
echo -e "\n${YELLOW}[4/4] Skipping frontend (backend only)${NC}"
fi
# Summary echo 'Frontend deployment complete'
echo -e "\n${GREEN}═══════════════════════════════════════════════════════════════${NC}" else
echo -e "${GREEN} ✅ DEPLOY COMPLETE! ${NC}" echo 'Build failed!'
echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}" exit 1
echo -e "" fi
echo -e " Frontend: ${BLUE}https://pounce.ch${NC} / http://$SERVER_HOST:3000" " 1
echo -e " Backend: ${BLUE}https://pounce.ch/api${NC} / http://$SERVER_HOST:8000"
echo -e "" return $?
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" # GIT FUNCTIONS
echo -e " ./deploy.sh \"message\" # Full deploy with commit message" # ============================================================================
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
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
log_warn "Push failed or nothing to push"
fi
}
# ============================================================================
# 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${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
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 " ${CYAN}Frontend:${NC} $FRONTEND_URL"
echo -e " ${CYAN}API:${NC} $API_URL"
echo -e " ${CYAN}Log:${NC} $LOG_FILE"
echo -e ""
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