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

703
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 ;; for host in "${SERVER_HOSTS[@]}"; do
*) COMMIT_MSG="$1" ;; log_debug "Trying $host..."
esac
shift # 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 done
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" log_error "No reachable server found!"
echo -e "${BLUE} POUNCE ZERO-DOWNTIME DEPLOY ${NC}" return 1
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}" }
if $QUICK_MODE; then # Test SSH connection
echo -e "${CYAN}⚡ Quick mode: Skipping git, only syncing changes${NC}" 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 fi
if $BACKEND_ONLY; then if [ $attempt -lt $SSH_RETRIES ]; then
echo -e "${CYAN}🔧 Backend only mode${NC}" 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
if $FRONTEND_ONLY; then for attempt in $(seq 1 $retries); do
echo -e "${CYAN}🎨 Frontend only mode${NC}" if sshpass -p "$SERVER_PASS" ssh $SSH_OPTS "$SERVER_USER@$SSH_HOST" "$cmd" 2>&1; then
return 0
fi fi
# Step 1: Git (unless quick mode) if [ $attempt -lt $retries ]; then
if ! $QUICK_MODE; then local wait=$((attempt * 2))
echo -e "\n${YELLOW}[1/4] Git operations...${NC}" log_debug "Command failed, retry $attempt/$retries in ${wait}s..."
sleep $wait
fi
done
# Check for changes (including untracked) return 1
if [ -z "$(git status --porcelain)" ]; then }
echo " No changes to commit"
# ============================================================================
# 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..."
fi
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 else
echo -e "\n${YELLOW}[1/4] Skipping git (quick mode)${NC}" log_error "Frontend health check failed (HTTP $status)"
return 1
fi fi
}
# Step 2: Sync files (only changed) wait_for_healthy() {
echo -e "\n${YELLOW}[2/4] Syncing changed files...${NC}" 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 log_info "Waiting for $service to be healthy (max ${max_wait}s)..."
RSYNC_OPTS="-avz --delete --compress-level=9 --checksum"
if ! $BACKEND_ONLY; then for i in $(seq 1 $max_wait); do
echo " Frontend:" if $check_func 2>/dev/null; then
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" $RSYNC_OPTS \ return 0
--exclude 'node_modules' \
--exclude '.next' \
--exclude '.git' \
frontend/ $SERVER_USER@$SERVER_HOST:$SERVER_PATH/frontend/
fi fi
sleep 1
printf "."
done
if ! $FRONTEND_ONLY; then echo ""
echo " Backend:" log_error "$service did not become healthy within ${max_wait}s"
sshpass -p "$SERVER_PASS" rsync -e "ssh $SSH_OPTS" $RSYNC_OPTS \ 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/' \
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 fi
# Step 3: Reload backend (graceful, no restart) remote_exec "
if ! $FRONTEND_ONLY; then cd $SERVER_PATH/backend
echo -e "\n${YELLOW}[3/4] Reloading backend (graceful)...${NC}"
sshpass -p "$SERVER_PASS" $SSH_CMD $SERVER_USER@$SERVER_HOST << 'BACKEND_EOF'
set -e
cd ~/pounce/backend # Activate virtualenv
if [ -f "venv/bin/activate" ]; then 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
fi nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/backend.log 2>&1 &
nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/pounce-backend-nohup.log 2>&1 & sleep 3
sleep 2
echo " ✓ Backend started (nohup fallback)"
fi
BACKEND_EOF
else
echo -e "\n${YELLOW}[3/4] Skipping backend (frontend only)${NC}"
fi fi
# Step 4: Rebuild frontend (in background to minimize downtime) echo 'Backend deployment complete'
if ! $BACKEND_ONLY; then " 3
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) return $?
LOCKFILE_HASH="" }
if [ -f ".lockfile_hash" ]; then
LOCKFILE_HASH=$(cat .lockfile_hash) 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
CURRENT_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d' ' -f1 || echo "none")
if [ "$LOCKFILE_HASH" != "$CURRENT_HASH" ]; then remote_exec "
echo " Installing dependencies (package-lock.json changed)..." 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 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 echo 'Frontend deployment complete'
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 else
echo " Starting Next.js (npm start)..." echo 'Build failed!'
nohup env NODE_ENV=production BACKEND_URL=http://127.0.0.1:8000 npm run start > /tmp/pounce-frontend-nohup.log 2>&1 & exit 1
fi fi
sleep 2 " 1
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 return $?
echo " ✓ Frontend running (nohup fallback, PID: $NEW_PID)" }
# ============================================================================
# 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 else
echo " ⚠ Frontend may not have started correctly" git add -A
tail -n 80 /tmp/pounce-frontend-nohup.log || true git commit -m "$msg" 2>&1 | tee -a "$LOG_FILE" || true
fi log_success "Committed: $msg"
fi fi
# Push
if git push origin main 2>&1 | tee -a "$LOG_FILE"; then
log_success "Pushed to remote"
else else
echo " ✗ Build failed, keeping old version" log_warn "Push failed or nothing to push"
echo " Last 120 lines of build output (frontend.log):"
tail -n 120 frontend.log || true
fi 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 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 fi
# Summary # Summary
echo -e "\n${GREEN}═══════════════════════════════════════════════════════════════${NC}" local end_time=$(date +%s)
echo -e "${GREEN} ✅ DEPLOY COMPLETE! ${NC}" local duration=$((end_time - start_time))
echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}"
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 ""
echo -e " Frontend: ${BLUE}https://pounce.ch${NC} / http://$SERVER_HOST:3000" echo -e " ${CYAN}Frontend:${NC} $FRONTEND_URL"
echo -e " Backend: ${BLUE}https://pounce.ch/api${NC} / http://$SERVER_HOST:8000" echo -e " ${CYAN}API:${NC} $API_URL"
echo -e " ${CYAN}Log:${NC} $LOG_FILE"
echo -e "" echo -e ""
echo -e "${CYAN}Quick commands:${NC}"
echo -e " ./deploy.sh -q # Quick sync, no git" return $errors
echo -e " ./deploy.sh -b # Backend only" }
echo -e " ./deploy.sh -f # Frontend only"
echo -e " ./deploy.sh \"message\" # Full deploy with commit message" # ============================================================================
# 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