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

759
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"
exit 1 "46.235.147.194"
fi "10.42.0.73"
)
# Parse flags # SSH options
QUICK_MODE=false SSH_TIMEOUT=15
BACKEND_ONLY=false SSH_RETRIES=3
FRONTEND_ONLY=false SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=$SSH_TIMEOUT -o ServerAliveInterval=10 -o ServerAliveCountMax=3"
COMMIT_MSG=""
while [[ "$#" -gt 0 ]]; do # URLs for health checks
case $1 in FRONTEND_URL="https://pounce.ch"
-q|--quick) QUICK_MODE=true ;; API_URL="https://pounce.ch/api/v1/health"
-b|--backend) BACKEND_ONLY=true ;;
-f|--frontend) FRONTEND_ONLY=true ;;
*) COMMIT_MSG="$1" ;;
esac
shift
done
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" # Log file
echo -e "${BLUE} POUNCE ZERO-DOWNTIME DEPLOY ${NC}" LOG_FILE="/tmp/pounce-deploy-$(date +%Y%m%d-%H%M%S).log"
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}"
if $QUICK_MODE; then # ============================================================================
echo -e "${CYAN}⚡ Quick mode: Skipping git, only syncing changes${NC}" # HELPER FUNCTIONS
fi # ============================================================================
if $BACKEND_ONLY; then log() {
echo -e "${CYAN}🔧 Backend only mode${NC}" local msg="[$(date '+%H:%M:%S')] $1"
fi echo -e "$msg" | tee -a "$LOG_FILE"
}
if $FRONTEND_ONLY; then log_success() { log "${GREEN}$1${NC}"; }
echo -e "${CYAN}🎨 Frontend only mode${NC}" log_error() { log "${RED}$1${NC}"; }
fi log_warn() { log "${YELLOW}$1${NC}"; }
log_info() { log "${BLUE}$1${NC}"; }
log_debug() { log "${GRAY} $1${NC}"; }
# Step 1: Git (unless quick mode) spinner() {
if ! $QUICK_MODE; then local pid=$1
echo -e "\n${YELLOW}[1/4] Git operations...${NC}" local delay=0.1
local spinstr='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
# Check for changes (including untracked) while kill -0 "$pid" 2>/dev/null; do
if [ -z "$(git status --porcelain)" ]; then local temp=${spinstr#?}
echo " No changes to commit" printf " %c " "$spinstr"
else local spinstr=$temp${spinstr%"$temp"}
git add -A sleep $delay
printf "\b\b\b\b"
done
printf " \b\b\b\b"
}
if [ -z "$COMMIT_MSG" ]; then # Check if command exists
COMMIT_MSG="Deploy: $(date '+%Y-%m-%d %H:%M')" 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 fi
exit 1
fi
}
git commit -m "$COMMIT_MSG" || true # ============================================================================
echo " ✓ Committed: $COMMIT_MSG" # CONNECTION FUNCTIONS
# ============================================================================
# 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
log_error "No reachable server found!"
return 1
}
# 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 [ $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 fi
git push origin main 2>/dev/null && echo " ✓ Pushed to git.6bit.ch" || echo " ⚠ Push failed or nothing to push" for attempt in $(seq 1 $retries); do
else if sshpass -p "$SERVER_PASS" ssh $SSH_OPTS "$SERVER_USER@$SSH_HOST" "$cmd" 2>&1; then
echo -e "\n${YELLOW}[1/4] Skipping git (quick mode)${NC}" return 0
fi 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
}
# Step 2: Sync files (only changed) # ============================================================================
echo -e "\n${YELLOW}[2/4] Syncing changed files...${NC}" # HEALTH CHECK FUNCTIONS
# ============================================================================
# Using compression (-z), checksum-based detection, and bandwidth throttling for stability check_api_health() {
RSYNC_OPTS="-avz --delete --compress-level=9 --checksum" 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
log_error "API health check failed"
log_debug "Response: $response"
return 1
fi
}
if ! $BACKEND_ONLY; then check_frontend_health() {
echo " Frontend:" log_info "Checking frontend health..."
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" $RSYNC_OPTS \
--exclude 'node_modules' \ local status
--exclude '.next' \ status=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 10 --max-time 30 "$FRONTEND_URL" 2>/dev/null)
--exclude '.git' \
frontend/ $SERVER_USER@$SERVER_HOST:$SERVER_PATH/frontend/ if [ "$status" = "200" ]; then
fi log_success "Frontend is healthy (HTTP $status)"
return 0
else
log_error "Frontend health check failed (HTTP $status)"
return 1
fi
}
if ! $FRONTEND_ONLY; then wait_for_healthy() {
echo " Backend:" local service="$1"
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" $RSYNC_OPTS \ local max_wait="${2:-60}"
local check_func="check_${service}_health"
log_info "Waiting for $service to be healthy (max ${max_wait}s)..."
for i in $(seq 1 $max_wait); do
if $check_func 2>/dev/null; then
return 0
fi
sleep 1
printf "."
done
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 '__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"
if [ ${PIPESTATUS[0]} -eq 0 ]; then
log_success "Backend files synced"
return 0
else
log_error "Backend sync failed"
return 1
fi
}
# Step 3: Reload backend (graceful, no restart) sync_frontend() {
if ! $FRONTEND_ONLY; then log_info "Syncing frontend files..."
echo -e "\n${YELLOW}[3/4] Reloading backend (graceful)...${NC}"
sshpass -p "$SERVER_PASS" $SSH_CMD $SERVER_USER@$SERVER_HOST << 'BACKEND_EOF' local host="${SSH_HOST:-$ACTIVE_HOST}"
set -e
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
}
cd ~/pounce/backend # ============================================================================
if [ -f "venv/bin/activate" ]; then # 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
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
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)"
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
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}"
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
# Check if package.json changed (skip npm ci if not)
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 # Run migrations
echo " Installing dependencies (package-lock.json changed)..." echo 'Running database migrations...'
python -c 'from app.database import init_db; import asyncio; asyncio.run(init_db())' 2>&1 || true
# 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 'Starting backend with nohup...'
pkill -f 'uvicorn app.main:app' 2>/dev/null || true
sleep 1
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
echo 'Backend deployment complete'
" 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
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 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 service
# Restart frontend via systemd when available (preferred). Fallback to nohup only if the unit is missing. if systemctl is-active --quiet pounce-frontend 2>/dev/null; then
if [ -f "/etc/systemd/system/pounce-frontend.service" ]; then echo 'Restarting frontend via systemd...'
echo " Restarting frontend via systemd..." echo '$SERVER_PASS' | sudo -S systemctl restart pounce-frontend
echo "user" | sudo -S systemctl restart pounce-frontend sleep 3
sleep 2
if systemctl is-active --quiet pounce-frontend; then
echo " ✓ Frontend restarted (systemd)"
else
echo " ⚠ Frontend restart failed (systemd). Check: journalctl -u pounce-frontend -n 80"
fi
else else
# Legacy nohup fallback echo 'Starting frontend with nohup...'
NEXT_PID=$(pgrep -af 'node \\.next/standalone/server\\.js|next start|next-server|next-serv' | awk 'NR==1{print $1; exit}') pkill -f 'node .next/standalone/server.js' 2>/dev/null || true
if [ -n "$NEXT_PID" ]; then lsof -ti:3000 | xargs -r kill -9 2>/dev/null || true
echo " Restarting Next.js (PID: $NEXT_PID)..."
kill $NEXT_PID 2>/dev/null
sleep 1
fi
lsof -ti:3000 2>/dev/null | xargs -r kill -9 2>/dev/null || true
sleep 1 sleep 1
if [ -f ".next/standalone/server.js" ]; then cd $SERVER_PATH/frontend
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/frontend.log 2>&1 &
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 & sleep 3
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 fi
echo 'Frontend deployment complete'
else else
echo " ✗ Build failed, keeping old version" echo 'Build failed!'
echo " Last 120 lines of build output (frontend.log):" exit 1
tail -n 120 frontend.log || true
fi fi
FRONTEND_EOF " 1
else
echo -e "\n${YELLOW}[4/4] Skipping frontend (backend only)${NC}" return $?
fi }
# Summary # ============================================================================
echo -e "\n${GREEN}═══════════════════════════════════════════════════════════════${NC}" # GIT FUNCTIONS
echo -e "${GREEN} ✅ DEPLOY COMPLETE! ${NC}" # ============================================================================
echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}"
echo -e "" git_commit_push() {
echo -e " Frontend: ${BLUE}https://pounce.ch${NC} / http://$SERVER_HOST:3000" local msg="${1:-Deploy: $(date '+%Y-%m-%d %H:%M')}"
echo -e " Backend: ${BLUE}https://pounce.ch/api${NC} / http://$SERVER_HOST:8000"
echo -e "" log_info "Git operations..."
echo -e "${CYAN}Quick commands:${NC}"
echo -e " ./deploy.sh -q # Quick sync, no git" # Check for changes
echo -e " ./deploy.sh -b # Backend only" if [ -z "$(git status --porcelain 2>/dev/null)" ]; then
echo -e " ./deploy.sh -f # Frontend only" log_debug "No changes to commit"
echo -e " ./deploy.sh \"message\" # Full deploy with commit message" 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