#!/bin/bash # ============================================================================== # DeepAIOCR - One Click Deployment Manager # ============================================================================== # Install (fresh server): # curl -fsSL https://install.deepaiocr.com/setup.sh | bash # # Local usage (after install): # ./setup.sh # Interactive menu # ./setup.sh install # First-time install and deploy # ./setup.sh start # Start all services # ./setup.sh stop # Stop all services # ./setup.sh restart # Restart all services # ./setup.sh update # Pull latest images and redeploy # ./setup.sh status # Show service status # ./setup.sh health # Health check all services # ./setup.sh logs [service] # View logs (all or specific service) # ./setup.sh backup # Backup configs and volumes # ./setup.sh uninstall # Remove all containers (keeps volumes) # ============================================================================== VERSION="1.0.0" INSTALL_URL="https://install.deepaiocr.com" DEPLOY_DIR="${HOME}/deepaiocr" # ────────────────────────────────────────────────────────────────────────────── # Bootstrap — runs when curl'd onto a fresh server (no docker-compose.yml here) # Detection: BASH_SOURCE[0] is empty/non-file when piped via curl | bash. # When re-exec'd as a real file, BASH_SOURCE[0] is the script path and # docker-compose.yml sits right next to it — so this block is skipped. # ────────────────────────────────────────────────────────────────────────────── _SELF="${BASH_SOURCE[0]:-}" # When piped via `curl | bash`, BASH_SOURCE[0] is empty. # dirname("") returns ".", so _SELF_DIR becomes CWD — which may already contain # docker-compose.yml from a prior install, causing bootstrap to be silently # skipped and dropping into the interactive menu with no TTY stdin (infinite loop). # Fix: treat any empty BASH_SOURCE[0] as "definitely bootstrapping". _SELF_DIR="$(cd "$(dirname "${_SELF:-.}")" 2>/dev/null && pwd || echo "")" if [ -z "$_SELF" ] || [ ! -f "${_SELF_DIR}/docker-compose.yml" ]; then # Minimal colors for bootstrap (before full color setup below) _GRN='\033[0;32m'; _RED='\033[0;31m'; _BLU='\033[0;34m'; _NC='\033[0m' echo "" echo -e "${_BLU} ┌─────────────────────────────────────────┐${_NC}" echo -e "${_BLU} │ DeepAIOCR — One Click Installer │${_NC}" echo -e "${_BLU} └─────────────────────────────────────────┘${_NC}" echo "" echo -e " Downloading deployment files to: ${_BLU}${DEPLOY_DIR}${_NC}" echo "" # Create directory structure mkdir -p "${DEPLOY_DIR}"/{caddy,cloudflare} || { echo -e " ${_RED}✗${_NC} Cannot create directory: ${DEPLOY_DIR}" exit 1 } # Files to download from install.deepaiocr.com DOWNLOAD_FILES=( "setup.sh" "docker-compose.yml" "caddy/Caddyfile" "cloudflare/config.yaml" ".env.example" ) # Prefer curl, fall back to wget _fetch() { local url="$1" dest="$2" if command -v curl &>/dev/null; then curl -fsSL "$url" -o "$dest" elif command -v wget &>/dev/null; then wget -qO "$dest" "$url" else echo -e " ${_RED}✗${_NC} Neither curl nor wget found. Install one and retry." exit 1 fi } for f in "${DOWNLOAD_FILES[@]}"; do if _fetch "${INSTALL_URL}/${f}" "${DEPLOY_DIR}/${f}" 2>/dev/null; then echo -e " ${_GRN}✓${_NC} $f" else echo -e " ${_RED}✗${_NC} Failed to download: $f" echo " Check your internet connection or visit ${INSTALL_URL}" exit 1 fi done chmod +x "${DEPLOY_DIR}/setup.sh" echo "" echo -e " ${_GRN}✓${_NC} All files downloaded." echo "" echo -e " ${_BLU}Launching setup wizard...${_NC}" echo "" # Re-exec the local copy with a proper terminal stdin. # cd first so BASH_SOURCE[0] resolves next to docker-compose.yml, # which prevents the bootstrap block from triggering again. cd "${DEPLOY_DIR}" && exec bash setup.sh install < /dev/tty exit fi # ────────────────────────────────────────────────────────────────────────────── # Normal mode — running from inside the deployment directory # ────────────────────────────────────────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" COMPOSE_CMD="" # ────────────────────────────────────────────────────────────────────────────── # Colors & Formatting # ────────────────────────────────────────────────────────────────────────────── RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' # ────────────────────────────────────────────────────────────────────────────── # UI Helpers # ────────────────────────────────────────────────────────────────────────────── print_banner() { echo "" echo -e "${BLUE}${BOLD}" echo " ██████╗ ███████╗███████╗██████╗ █████╗ ██╗ ██████╗ ██████╗██████╗ " echo " ██╔══██╗██╔════╝██╔════╝██╔══██╗██╔══██╗██║██╔═══██╗██╔════╝██╔══██╗" echo " ██║ ██║█████╗ █████╗ ██████╔╝███████║██║██║ ██║██║ ██████╔╝" echo " ██║ ██║██╔══╝ ██╔══╝ ██╔═══╝ ██╔══██║██║██║ ██║██║ ██╔══██╗" echo " ██████╔╝███████╗███████╗██║ ██║ ██║██║╚██████╔╝╚██████╗██║ ██║" echo " ╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═════╝ ╚═════╝╚═╝ ╚═╝" echo -e "${NC}" echo -e " ${DIM}One-Click Deployment Manager v${VERSION}${NC}" echo "" } print_header() { echo "" echo -e "${BLUE}${BOLD}┌──────────────────────────────────────────────────────────────┐${NC}" printf "${BLUE}${BOLD}│ %-60s│${NC}\n" "$1" echo -e "${BLUE}${BOLD}└──────────────────────────────────────────────────────────────┘${NC}" echo "" } print_step() { echo -e "${CYAN}${BOLD}▶ $1${NC}" } print_success() { echo -e " ${GREEN}✓${NC} $1" } print_error() { echo -e " ${RED}✗${NC} $1" } print_warning() { echo -e " ${YELLOW}⚠${NC} $1" } print_info() { echo -e " ${BLUE}ℹ${NC} $1" } print_divider() { echo -e "${DIM}──────────────────────────────────────────────────────────────${NC}" } confirm() { local prompt="${1:-Are you sure?}" echo -ne " ${YELLOW}?${NC} ${prompt} ${DIM}[y/N]${NC} " read -r response [[ "$response" =~ ^[Yy]$ ]] } # ────────────────────────────────────────────────────────────────────────────── # Docker Compose Detection # ────────────────────────────────────────────────────────────────────────────── detect_compose_cmd() { if docker compose version &>/dev/null 2>&1; then COMPOSE_CMD="docker compose" elif command -v docker-compose &>/dev/null; then COMPOSE_CMD="docker-compose" else return 1 fi } run_compose() { $COMPOSE_CMD "$@" } # ────────────────────────────────────────────────────────────────────────────── # Prerequisites # ────────────────────────────────────────────────────────────────────────────── check_prerequisites() { print_header "Checking Prerequisites" local all_ok=true # Docker if command -v docker &>/dev/null && docker info &>/dev/null 2>&1; then print_success "Docker $(docker --version | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')" elif command -v docker &>/dev/null; then print_error "Docker is installed but not running. Start Docker and try again." all_ok=false else print_error "Docker is not installed." echo "" print_info "Install Docker with:" echo " curl -fsSL https://get.docker.com | sh" echo " sudo usermod -aG docker \$USER && newgrp docker" all_ok=false fi # Docker Compose if detect_compose_cmd; then print_success "Docker Compose ($COMPOSE_CMD)" else print_error "Docker Compose not found." print_info "Install via: sudo apt-get install docker-compose-plugin" all_ok=false fi # gcloud (optional but recommended for image pulling) if command -v gcloud &>/dev/null; then print_success "gcloud CLI ($(gcloud --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1))" else print_warning "gcloud CLI not found — needed to pull images from GCP Artifact Registry." print_info "Install: https://cloud.google.com/sdk/docs/install" print_info "Skipping gcloud check. Images must be pre-pulled or manually authenticated." fi # curl / wget if command -v curl &>/dev/null || command -v wget &>/dev/null; then print_success "HTTP client (curl/wget) available" else print_warning "Neither curl nor wget found — health checks will be limited." fi echo "" if [ "$all_ok" = false ]; then print_error "One or more required prerequisites are missing. Fix them and re-run." exit 1 fi print_success "All prerequisites satisfied." } # ────────────────────────────────────────────────────────────────────────────── # GCP Authentication # ────────────────────────────────────────────────────────────────────────────── authenticate_gcp() { print_header "GCP Artifact Registry Authentication" if ! command -v gcloud &>/dev/null; then print_warning "gcloud CLI not found — skipping GCP authentication." print_info "If image pulls fail, authenticate manually:" echo " gcloud auth configure-docker asia-south1-docker.pkg.dev" return fi print_step "Configuring Docker to use GCP Artifact Registry..." if gcloud auth configure-docker asia-south1-docker.pkg.dev --quiet 2>/dev/null; then print_success "GCP Artifact Registry authenticated." else print_warning "GCP auth configuration had issues — attempting active login..." if gcloud auth login --no-launch-browser 2>/dev/null; then gcloud auth configure-docker asia-south1-docker.pkg.dev --quiet 2>/dev/null || true print_success "GCP authenticated via browser login." else print_warning "GCP authentication skipped. Image pulls may fail if not pre-authenticated." fi fi } # ────────────────────────────────────────────────────────────────────────────── # Environment File Management # ────────────────────────────────────────────────────────────────────────────── # Parse .env.example and write each section into its corresponding .env.* file. # # Section headers in .env.example look like (always sandwiched between dividers): # # ===========... # # .env.shared (description) # # ===========... # # The top-of-file comment block also references .env.* names (as a list) but # those lines are indented with 3+ spaces: "# .env.cloudflare → ..." # We only treat a line as a section header when it immediately follows a "# ===" divider, # which guarantees we never confuse list items with real section headers. seed_env_files_from_example() { local example="${1:-.env.example}" [ ! -f "$example" ] && return local current_file="" local after_divider=false # true when the previous non-blank line was a === divider while IFS= read -r line; do # Detect === divider lines if [[ "$line" =~ ^#[[:space:]]*={10,} ]]; then if [ "$after_divider" = true ]; then # Closing divider after a section header — keep current_file active, # but next real content line belongs to this section. after_divider=false else # Opening divider — next line may be a section header after_divider=true fi continue fi # Detect section header ONLY when the previous line was an opening divider if [ "$after_divider" = true ] && [[ "$line" =~ ^#[[:space:]]+(\.env\.[a-zA-Z_-]+) ]]; then local candidate="${BASH_REMATCH[1]}" current_file="" for f in "${REQUIRED_ENV_FILES[@]}"; do if [ "$f" = "$candidate" ] && [ ! -f "$f" ]; then current_file="$f" break fi done # after_divider stays true so the closing === line (next) is handled above continue fi after_divider=false [ -n "$current_file" ] && echo "$line" >> "$current_file" done < "$example" } REQUIRED_ENV_FILES=( ".env.shared" ".env.frontend" ".env.backend" ".env.logging-api" ".env.safety-check" ".env.presidio" ".env.polling-processor" ".env.user-prompt-lib" ".env.caddy" ".env.cloudflare" ) validate_env_files() { print_header "Validating Environment Files" cd "$SCRIPT_DIR" local all_present=true local missing=() for f in "${REQUIRED_ENV_FILES[@]}"; do if [ -f "$f" ]; then print_success "$f" else print_error "$f ${DIM}(missing)${NC}" missing+=("$f") all_present=false fi done echo "" if [ "$all_present" = false ]; then echo -e " ${RED}Missing ${#missing[@]} env file(s).${NC}" echo "" print_info "Run the env setup wizard to create them:" echo " ./setup.sh env-setup" echo "" print_info "Or copy the example template:" echo " cp .env.example .env.shared # then edit with real values" return 1 fi # Check critical variables are not placeholder values print_step "Checking critical variable values..." local shared_issues=0 check_var_set() { local file="$1" var="$2" local val val=$(grep -E "^${var}=" "$file" 2>/dev/null | cut -d= -f2- | tr -d '"' | tr -d "'") if [ -z "$val" ] || [[ "$val" == *"your-"* ]] || [[ "$val" == *"<"* ]]; then print_warning "$var in $file appears unset or is a placeholder." shared_issues=$((shared_issues + 1)) fi } check_var_set ".env.shared" "DOMAIN" check_var_set ".env.shared" "DB_PASSWORD" check_var_set ".env.shared" "GEMINI_API_KEY_PRIMARY" check_var_set ".env.shared" "FRONTEND_IMAGE" check_var_set ".env.shared" "BACKEND_IMAGE" check_var_set ".env.cloudflare" "CLOUDFLARE_TUNNEL_TOKEN" if [ "$shared_issues" -gt 0 ]; then print_warning "$shared_issues variable(s) may need to be updated in your .env files." else print_success "All critical variables are set." fi return 0 } generate_compose_env() { # Creates the root .env that docker-compose uses for variable substitution # It's a merge of .env.shared (which has the image names etc.) cd "$SCRIPT_DIR" if [ -f ".env.shared" ]; then cp .env.shared .env print_success "Generated .env from .env.shared (docker-compose variable substitution)" else print_error ".env.shared not found — cannot generate .env" return 1 fi } generate_cloudflare_config() { # Substitute ${DOMAIN} and ${TUNNEL_NAME} in config.yaml from .env.cloudflare / .env.shared cd "$SCRIPT_DIR" local template="cloudflare/config.yaml.template" local output="cloudflare/config.yaml" if [ ! -f "$template" ]; then # Use existing config.yaml if no template found if [ -f "$output" ]; then print_info "Using existing cloudflare/config.yaml" return fi print_warning "cloudflare/config.yaml.template not found — skipping substitution." return fi # Load variables local domain tunnel_name domain=$(grep -E "^DOMAIN=" .env.shared 2>/dev/null | cut -d= -f2- | tr -d '"') tunnel_name=$(grep -E "^TUNNEL_NAME=" .env.cloudflare 2>/dev/null | cut -d= -f2- | tr -d '"') domain="${domain:-your-domain.com}" tunnel_name="${tunnel_name:-deepaiocr-tunnel}" sed -e "s|\${DOMAIN}|${domain}|g" \ -e "s|\${TUNNEL_NAME}|${tunnel_name}|g" \ "$template" > "$output" print_success "Generated cloudflare/config.yaml (domain: ${domain})" } env_setup_wizard() { print_header "Environment Setup Wizard" echo -e " This wizard helps you configure the minimum required variables." echo -e " ${DIM}For full configuration, edit the .env.* files directly after setup.${NC}" echo "" cd "$SCRIPT_DIR" # Seed each .env.* file from the matching section in .env.example. # This gives customers a pre-populated template with all keys visible, # rather than empty files that give no indication of what to fill in. if [ -f ".env.example" ]; then seed_env_files_from_example ".env.example" for f in "${REQUIRED_ENV_FILES[@]}"; do if [ -f "$f" ] && [ -s "$f" ]; then print_success "Created $f ${DIM}(pre-populated from .env.example)${NC}" elif [ ! -f "$f" ]; then # Section wasn't found in .env.example — create a minimal stub touch "$f" print_warning "Created empty $f ${DIM}(no matching section in .env.example)${NC}" fi done else print_warning ".env.example not found — creating empty env files." for f in "${REQUIRED_ENV_FILES[@]}"; do [ ! -f "$f" ] && touch "$f" && print_info "Created empty $f" done fi echo "" print_step "Enter your domain (e.g. app.example.com):" local domain_input="" while [ -z "$domain_input" ]; do echo -ne " ${CYAN}Domain${NC}: " read -r domain_input [ -z "$domain_input" ] && print_warning "Domain is required — cannot be blank." done # Upsert: replace existing line or append if file is empty / key absent if grep -q "^DOMAIN=" .env.shared 2>/dev/null; then sed -i.bak "s|^DOMAIN=.*|DOMAIN=${domain_input}|" .env.shared && rm -f .env.shared.bak else echo "DOMAIN=${domain_input}" >> .env.shared fi print_success "Domain set to: $domain_input" echo "" print_step "Enter your Cloudflare Tunnel Token:" print_info "Get it from: Cloudflare Zero Trust → Networks → Tunnels → your tunnel → Configure" echo -ne " ${CYAN}Token${NC}: " read -r cf_token if [ -n "$cf_token" ]; then # Upsert: replace existing line or append if key absent if grep -q "^CLOUDFLARE_TUNNEL_TOKEN=" .env.cloudflare 2>/dev/null; then sed -i.bak "s|^CLOUDFLARE_TUNNEL_TOKEN=.*|CLOUDFLARE_TUNNEL_TOKEN=${cf_token}|" .env.cloudflare && rm -f .env.cloudflare.bak else echo "CLOUDFLARE_TUNNEL_TOKEN=${cf_token}" >> .env.cloudflare fi print_success "Cloudflare tunnel token set." else print_warning "No Cloudflare token entered — tunnel will not connect until you set CLOUDFLARE_TUNNEL_TOKEN in .env.cloudflare" fi echo "" print_info "Wizard complete. Next steps:" echo "" echo " 1. Open and fill in ALL required values in these files:" for f in "${REQUIRED_ENV_FILES[@]}"; do echo " $f" done echo "" echo " 2. Required fields to set (minimum):" echo " .env.shared → FRONTEND_IMAGE, BACKEND_IMAGE, LOGGING_API_IMAGE," echo " SAFETY_CHECK_IMAGE, PRESIDIO_IMAGE, POLLING_PROCESSOR_IMAGE," echo " USER_PROMPT_LIB_IMAGE, DB_PASSWORD, GEMINI_API_KEY_PRIMARY" echo " .env.cloudflare → CLOUDFLARE_TUNNEL_TOKEN" echo "" print_info "Then re-run: ./setup.sh install" } # ────────────────────────────────────────────────────────────────────────────── # Image Management # ────────────────────────────────────────────────────────────────────────────── pull_images() { print_header "Pulling Docker Images" cd "$SCRIPT_DIR" print_step "Loading image references from .env.shared..." set -a # shellcheck disable=SC1091 source .env.shared set +a local images=( "${FRONTEND_IMAGE:-}" "${BACKEND_IMAGE:-}" "${LOGGING_API_IMAGE:-}" "${SAFETY_CHECK_IMAGE:-}" "${PRESIDIO_IMAGE:-}" "${POLLING_PROCESSOR_IMAGE:-}" "${USER_PROMPT_LIB_IMAGE:-}" ) local success_count=0 local fail_count=0 for image in "${images[@]}"; do [ -z "$image" ] && continue local service_name service_name=$(echo "$image" | grep -oE '[^/]+:[^:]+$' | cut -d: -f1 | head -c 30) print_step "Pulling ${service_name}..." if docker pull "$image" 2>&1 | tail -1; then print_success "Pulled: $image" success_count=$((success_count + 1)) else print_warning "Failed to pull: $image (may use cached version)" fail_count=$((fail_count + 1)) fi echo "" done # Caddy and Cloudflared use public images, pull them via compose print_step "Pulling infrastructure images (Caddy, Cloudflared)..." run_compose pull caddy cloudflare-tunnel 2>/dev/null || true echo "" if [ "$success_count" -eq 0 ] && [ "$fail_count" -eq 0 ]; then print_warning "No application images were pulled — IMAGE variables are not set in .env.shared." print_info "Set FRONTEND_IMAGE, BACKEND_IMAGE, etc., then re-run: ./setup.sh install" else print_success "Image pull complete: ${success_count} succeeded, ${fail_count} failed." fi } # ────────────────────────────────────────────────────────────────────────────── # Image Var Guard — abort with a clear message if image vars are unconfigured # ────────────────────────────────────────────────────────────────────────────── assert_images_configured() { cd "$SCRIPT_DIR" local backend_image backend_image=$(grep -E "^BACKEND_IMAGE=" .env.shared 2>/dev/null | cut -d= -f2- | tr -d '"') if [ -z "${backend_image}" ]; then print_error "Image variables are not configured in .env.shared" print_info "Set BACKEND_IMAGE, FRONTEND_IMAGE, and other *_IMAGE variables, then re-run:" echo "" echo " ./setup.sh install" echo "" exit 1 fi } # ────────────────────────────────────────────────────────────────────────────── # Service Management # ────────────────────────────────────────────────────────────────────────────── start_services() { print_header "Starting Services" cd "$SCRIPT_DIR" generate_compose_env assert_images_configured print_step "Bringing up all 9 services..." echo "" if run_compose up -d --remove-orphans; then echo "" print_success "All services started." else echo "" print_error "Failed to start services. Check the output above for details." print_info "View logs with: ./setup.sh logs" exit 1 fi } stop_services() { print_header "Stopping Services" cd "$SCRIPT_DIR" print_step "Stopping all services..." run_compose down echo "" print_success "All services stopped." } restart_services() { print_header "Restarting Services" cd "$SCRIPT_DIR" generate_compose_env if [ -n "${1:-}" ]; then print_step "Restarting service: $1" run_compose restart "$1" print_success "$1 restarted." else print_step "Restarting all services..." run_compose restart print_success "All services restarted." fi } # ────────────────────────────────────────────────────────────────────────────── # Health Checking # ────────────────────────────────────────────────────────────────────────────── SERVICES_AND_CONTAINERS=( "frontend:deepaiocr-frontend" "backend-service:deepaiocr-backend" "logging-api:deepaiocr-logging-api" "safety-check-api:deepaiocr-safety-check" "presidio-paddleocr:deepaiocr-presidio-paddleocr" "polling-processor:deepaiocr-polling-processor" "user-prompt-lib-api:deepaiocr-user-prompt-lib" "caddy:deepaiocr-caddy" "cloudflare-tunnel:deepaiocr-cloudflare-tunnel" ) wait_for_services() { local max_wait="${1:-300}" # 5 minutes default local interval=10 local elapsed=0 print_header "Waiting for Services to Start" print_info "Max wait: ${max_wait}s. Services with large models (presidio, safety-check) may take 2-5 minutes." echo "" while [ "$elapsed" -lt "$max_wait" ]; do local running=0 local total=${#SERVICES_AND_CONTAINERS[@]} for entry in "${SERVICES_AND_CONTAINERS[@]}"; do local container="${entry#*:}" if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${container}$"; then running=$((running + 1)) fi done printf "\r ${BLUE}ℹ${NC} Running: ${GREEN}%d${NC}/%d ⏱ %ds elapsed" "$running" "$total" "$elapsed" if [ "$running" -eq "$total" ]; then echo "" print_success "All $total containers are running." return 0 fi sleep "$interval" elapsed=$((elapsed + interval)) done echo "" print_warning "Timeout reached. Some services may still be starting (check with: ./setup.sh status)." return 0 } check_health() { print_header "Service Health Check" cd "$SCRIPT_DIR" local healthy=0 local unhealthy=0 local starting=0 local not_running=0 echo "" printf " %-35s %-15s %s\n" "SERVICE" "STATUS" "HEALTH" print_divider for entry in "${SERVICES_AND_CONTAINERS[@]}"; do local service="${entry%%:*}" local container="${entry#*:}" if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${container}$"; then local health health=$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}no-check{{end}}' "$container" 2>/dev/null || echo "unknown") local uptime uptime=$(docker inspect --format='{{.State.StartedAt}}' "$container" 2>/dev/null | cut -c1-19 || echo "") case "$health" in healthy) printf " %-35s ${GREEN}%-15s${NC} %s\n" "$service" "running" "healthy ✓" healthy=$((healthy + 1)) ;; starting) printf " %-35s ${YELLOW}%-15s${NC} %s\n" "$service" "running" "starting..." starting=$((starting + 1)) ;; no-check|"") printf " %-35s ${GREEN}%-15s${NC} %s\n" "$service" "running" "no healthcheck" healthy=$((healthy + 1)) ;; *) printf " %-35s ${YELLOW}%-15s${NC} %s\n" "$service" "running" "unhealthy ✗" unhealthy=$((unhealthy + 1)) ;; esac else printf " %-35s ${RED}%-15s${NC} %s\n" "$service" "not running" "—" not_running=$((not_running + 1)) fi done echo "" print_divider echo "" # System resources local disk_pct disk_pct=$(df -h / 2>/dev/null | awk 'NR==2{print $5}' | tr -d '%') local mem_pct mem_pct=$(free 2>/dev/null | awk '/Mem/{printf "%.0f", $3/$2*100}' || echo "N/A") if [ "$disk_pct" != "" ]; then if [ "$disk_pct" -lt 80 ]; then print_success "Disk usage: ${disk_pct}%" elif [ "$disk_pct" -lt 90 ]; then print_warning "Disk usage: ${disk_pct}% (getting high)" else print_error "Disk usage: ${disk_pct}% (critically high!)" fi fi if [ "$mem_pct" != "N/A" ]; then if [ "$mem_pct" -lt 80 ]; then print_success "Memory usage: ${mem_pct}%" elif [ "$mem_pct" -lt 90 ]; then print_warning "Memory usage: ${mem_pct}% (getting high)" else print_error "Memory usage: ${mem_pct}% (critically high!)" fi fi echo "" echo -e " ${BOLD}Summary:${NC} ${GREEN}${healthy} healthy${NC} ${YELLOW}${starting} starting${NC} ${RED}${unhealthy} unhealthy${NC} ${RED}${not_running} not running${NC}" echo "" if [ "$not_running" -eq 0 ] && [ "$unhealthy" -eq 0 ]; then return 0 else return 1 fi } # ────────────────────────────────────────────────────────────────────────────── # Status # ────────────────────────────────────────────────────────────────────────────── show_status() { print_header "Service Status" cd "$SCRIPT_DIR" run_compose ps 2>/dev/null || true echo "" # Load domain local domain="your-domain.com" if [ -f ".env.shared" ]; then local d d=$(grep -E "^DOMAIN=" .env.shared 2>/dev/null | cut -d= -f2- | tr -d '"') [ -n "$d" ] && domain="$d" fi echo "" print_divider echo "" echo -e " ${BOLD}Application URL:${NC} https://${domain}" echo "" echo -e " ${BOLD}Useful commands:${NC}" echo -e " ${DIM}./setup.sh health${NC} Check service health" echo -e " ${DIM}./setup.sh logs${NC} View all logs" echo -e " ${DIM}./setup.sh logs backend-service${NC} View backend logs" echo -e " ${DIM}./setup.sh restart${NC} Restart all services" echo -e " ${DIM}./setup.sh update${NC} Pull latest images" echo "" } # ────────────────────────────────────────────────────────────────────────────── # Logs # ────────────────────────────────────────────────────────────────────────────── show_logs() { cd "$SCRIPT_DIR" generate_compose_env 2>/dev/null || true assert_images_configured local service="${1:-}" if [ -n "$service" ]; then print_header "Logs: $service" run_compose logs -f --tail=100 "$service" else print_header "All Service Logs" echo -e " ${DIM}Select a service to view logs (or press Enter for all):${NC}" echo "" echo " [1] frontend" echo " [2] backend-service" echo " [3] logging-api" echo " [4] safety-check-api" echo " [5] presidio-paddleocr" echo " [6] polling-processor" echo " [7] user-prompt-lib-api" echo " [8] caddy" echo " [9] cloudflare-tunnel" echo " [0] All services" echo "" echo -ne " ${CYAN}Choice${NC} [0-9]: " read -r log_choice case "$log_choice" in 1) run_compose logs -f --tail=200 frontend ;; 2) run_compose logs -f --tail=200 backend-service ;; 3) run_compose logs -f --tail=200 logging-api ;; 4) run_compose logs -f --tail=200 safety-check-api ;; 5) run_compose logs -f --tail=200 presidio-paddleocr ;; 6) run_compose logs -f --tail=200 polling-processor ;; 7) run_compose logs -f --tail=200 user-prompt-lib-api ;; 8) run_compose logs -f --tail=200 caddy ;; 9) run_compose logs -f --tail=200 cloudflare-tunnel ;; *) run_compose logs -f --tail=100 ;; esac fi } # ────────────────────────────────────────────────────────────────────────────── # Backup # ────────────────────────────────────────────────────────────────────────────── backup() { print_header "Backup" cd "$SCRIPT_DIR" local backup_dir="./backups/$(date +%Y%m%d_%H%M%S)" mkdir -p "$backup_dir" print_step "Backing up environment files..." for f in .env .env.shared .env.backend .env.frontend .env.logging-api \ .env.safety-check .env.presidio .env.polling-processor \ .env.user-prompt-lib .env.caddy .env.cloudflare; do [ -f "$f" ] && cp "$f" "$backup_dir/" && print_success " $f" done print_step "Backing up config files..." cp -r caddy/ "$backup_dir/caddy/" 2>/dev/null && print_success " caddy/Caddyfile" cp -r cloudflare/ "$backup_dir/cloudflare/" 2>/dev/null && print_success " cloudflare/config.yaml" cp docker-compose.yml "$backup_dir/" && print_success " docker-compose.yml" print_step "Backing up Docker volumes..." local volumes volumes=$(docker volume ls --filter "name=one-click-deployment" --format "{{.Name}}" 2>/dev/null || \ docker volume ls --filter "label=com.deepaiocr" --format "{{.Name}}" 2>/dev/null || \ echo "") if [ -n "$volumes" ]; then for vol in $volumes; do print_info "Exporting volume: $vol" docker run --rm -v "${vol}:/data" -v "${PWD}/${backup_dir}:/backup" \ alpine tar -czf "/backup/${vol}.tar.gz" -C /data . 2>/dev/null && \ print_success " $vol" done else print_info "No named volumes found to back up." fi echo "" print_success "Backup saved to: ${backup_dir}" du -sh "$backup_dir" 2>/dev/null | awk '{print " Size: "$1}' } # ────────────────────────────────────────────────────────────────────────────── # Update # ────────────────────────────────────────────────────────────────────────────── update_services() { print_header "Update Services" cd "$SCRIPT_DIR" local target="${1:-}" authenticate_gcp generate_compose_env if [ -n "$target" ]; then print_step "Pulling latest image for: $target" run_compose pull "$target" print_step "Recreating $target..." run_compose up -d --force-recreate "$target" print_success "$target updated." else print_step "Pulling latest images for all services..." # Pull custom images individually (they reference specific GCP paths) set -a # shellcheck disable=SC1091 source .env.shared set +a local images=( "${FRONTEND_IMAGE:-}" "${BACKEND_IMAGE:-}" "${LOGGING_API_IMAGE:-}" "${SAFETY_CHECK_IMAGE:-}" "${PRESIDIO_IMAGE:-}" "${POLLING_PROCESSOR_IMAGE:-}" "${USER_PROMPT_LIB_IMAGE:-}" ) for image in "${images[@]}"; do [ -z "$image" ] && continue print_step "Pulling $image..." docker pull "$image" 2>&1 | tail -1 || print_warning "Failed to pull $image" done # Pull public images via compose run_compose pull caddy cloudflare-tunnel 2>/dev/null || true print_step "Recreating all containers with new images..." run_compose up -d --force-recreate print_success "All services updated and restarted." fi } # ────────────────────────────────────────────────────────────────────────────── # Uninstall # ────────────────────────────────────────────────────────────────────────────── uninstall_services() { print_header "Uninstall" echo "" print_warning "This will stop and remove all DeepAIOCR containers." print_info "Docker volumes (database, uploads, logs, cache) will be PRESERVED." echo "" if ! confirm "Proceed with uninstall?"; then print_info "Uninstall cancelled." return fi cd "$SCRIPT_DIR" print_step "Stopping and removing containers..." run_compose down --remove-orphans echo "" print_success "All containers removed. Volumes retained." echo "" print_info "To also remove volumes (DATA LOSS):" echo " $COMPOSE_CMD down -v" echo "" print_info "To completely remove all Docker resources:" echo " docker system prune -a --volumes" } # ────────────────────────────────────────────────────────────────────────────── # Install (First-Time Full Setup) # ────────────────────────────────────────────────────────────────────────────── cmd_install() { print_banner print_header "First-Time Install" # 1. Prerequisites check_prerequisites # 2. Detect compose command (may have been set in check_prerequisites) detect_compose_cmd || true # 3. Validate env files if ! validate_env_files; then echo "" if confirm "Some env files are missing. Run setup wizard now?"; then env_setup_wizard echo "" print_info "Env files have been created. Fill in all required values, then re-run:" echo "" echo " ./setup.sh install" echo "" exit 0 else print_info "Create the missing .env files manually, then re-run: ./setup.sh install" exit 1 fi fi # 4. GCP auth + pull images authenticate_gcp pull_images # 5. Generate derived configs generate_compose_env generate_cloudflare_config # 6. Start services start_services # 7. Wait for startup wait_for_services 300 # 8. Health check check_health || true # 9. Success print_header "Installation Complete" local domain="your-domain.com" if [ -f ".env.shared" ]; then local d d=$(grep -E "^DOMAIN=" .env.shared 2>/dev/null | cut -d= -f2- | tr -d '"') [ -n "$d" ] && domain="$d" fi echo "" echo -e " ${GREEN}${BOLD}DeepAIOCR is up and running!${NC}" echo "" echo -e " ${BOLD}Application:${NC} https://${domain}" echo "" echo -e " ${BOLD}Management commands:${NC}" echo -e " ${DIM}./setup.sh status${NC} View service status" echo -e " ${DIM}./setup.sh health${NC} Check service health" echo -e " ${DIM}./setup.sh logs${NC} View logs" echo -e " ${DIM}./setup.sh update${NC} Update to latest images" echo -e " ${DIM}./setup.sh backup${NC} Backup configuration" echo -e " ${DIM}./setup.sh stop${NC} Stop all services" echo "" print_divider } # ────────────────────────────────────────────────────────────────────────────── # Interactive Menu # ────────────────────────────────────────────────────────────────────────────── show_menu() { print_banner # Quick status line local running=0 local total=${#SERVICES_AND_CONTAINERS[@]} for entry in "${SERVICES_AND_CONTAINERS[@]}"; do local container="${entry#*:}" docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${container}$" && running=$((running + 1)) done if [ "$running" -eq "$total" ]; then echo -e " Status: ${GREEN}${running}/${total} services running${NC}" elif [ "$running" -gt 0 ]; then echo -e " Status: ${YELLOW}${running}/${total} services running${NC}" else echo -e " Status: ${RED}0/${total} services running${NC}" fi echo "" print_divider echo "" echo -e " ${BOLD}[1]${NC} Install / First-Time Deploy" echo -e " ${BOLD}[2]${NC} Start Services" echo -e " ${BOLD}[3]${NC} Stop Services" echo -e " ${BOLD}[4]${NC} Restart Services" echo -e " ${BOLD}[5]${NC} Update ${DIM}(pull latest images + redeploy)${NC}" echo -e " ${BOLD}[6]${NC} Status ${DIM}(docker compose ps)${NC}" echo -e " ${BOLD}[7]${NC} Health Check" echo -e " ${BOLD}[8]${NC} View Logs" echo -e " ${BOLD}[9]${NC} Backup" echo -e " ${BOLD}[0]${NC} Uninstall" echo "" print_divider echo "" echo -ne " ${CYAN}Select option${NC} [0-9, q to quit]: " } interactive_loop() { # Guard: stdin must be a real terminal. When invoked via `curl | bash` with # no TTY, read() returns EOF immediately, causing an infinite invalid-option loop. if [ ! -t 0 ]; then echo "" print_error "Interactive menu requires a terminal. Did you mean to install?" echo "" print_info "Run the installer with:" echo "" echo " curl -fsSL https://install.deepaiocr.com/setup.sh | bash" echo "" echo " Or, if already downloaded:" echo "" echo " ./setup.sh install" echo "" exit 1 fi while true; do show_menu read -r choice echo "" case "$choice" in 1) cmd_install ;; 2) check_prerequisites detect_compose_cmd || true validate_env_files && start_services && wait_for_services 120 && check_health || true ;; 3) detect_compose_cmd || true stop_services ;; 4) detect_compose_cmd || true restart_services ;; 5) check_prerequisites detect_compose_cmd || true update_services wait_for_services 120 check_health || true ;; 6) detect_compose_cmd || true show_status ;; 7) check_health ;; 8) detect_compose_cmd || true show_logs ;; 9) detect_compose_cmd || true backup ;; 0) detect_compose_cmd || true uninstall_services ;; q|Q|quit|exit) echo -e " Goodbye!" exit 0 ;; *) print_warning "Invalid option: '$choice'. Enter 0-9 or q." ;; esac echo "" echo -ne " ${DIM}Press Enter to return to menu...${NC}" read -r done } # ────────────────────────────────────────────────────────────────────────────── # CLI Entry Point # ────────────────────────────────────────────────────────────────────────────── show_usage() { print_banner echo " Usage: ./setup.sh [command] [options]" echo "" echo " Commands:" echo " install First-time setup and deploy all services" echo " start Start all services" echo " stop Stop all services" echo " restart [service] Restart all or a specific service" echo " update [service] Pull latest images and redeploy" echo " status Show docker compose status" echo " health Check health of all services" echo " logs [service] View logs (interactive if no service given)" echo " backup Backup configs and volumes" echo " uninstall Remove containers (keeps volumes)" echo " env-setup Run the environment setup wizard" echo "" echo " Examples:" echo " ./setup.sh install" echo " ./setup.sh logs backend-service" echo " ./setup.sh update frontend" echo " ./setup.sh restart polling-processor" echo "" } main() { # Always detect compose command first detect_compose_cmd 2>/dev/null || true local command="${1:-}" case "$command" in install) cmd_install ;; start) check_prerequisites detect_compose_cmd validate_env_files start_services wait_for_services 120 check_health || true ;; stop) detect_compose_cmd stop_services ;; restart) detect_compose_cmd generate_compose_env restart_services "${2:-}" ;; update) check_prerequisites detect_compose_cmd update_services "${2:-}" ;; status) detect_compose_cmd show_status ;; health) check_health ;; logs) detect_compose_cmd show_logs "${2:-}" ;; backup) detect_compose_cmd backup ;; uninstall) detect_compose_cmd uninstall_services ;; env-setup) env_setup_wizard ;; help|--help|-h) show_usage ;; "") # No arguments — interactive menu interactive_loop ;; *) print_error "Unknown command: $command" show_usage exit 1 ;; esac } main "$@"