#!/bin/bash set -euo pipefail BOLD=$(tput bold) GREEN=$(tput setaf 2) YELLOW=$(tput setaf 3) RED=$(tput setaf 1) CYAN=$(tput setaf 6) RESET=$(tput sgr0) # Rollback tracking INSTALL_DIR="/opt/zensailor" CREATED_INSTALL_DIR=false CREATED_NETWORK=false INSTALLATION_COMPLETE=false # Cleanup function - only runs if we created the install dir and installation didn't complete cleanup_on_failure() { if [ "$INSTALLATION_COMPLETE" = true ]; then return 0 fi echo echo_bold "${RED}⚠ Installation failed. Rolling back changes...${RESET}" # Only remove install dir if WE created it (not an existing installation) if [ "$CREATED_INSTALL_DIR" = true ] && [ -d "$INSTALL_DIR" ]; then echo_yellow "Removing $INSTALL_DIR..." sudo rm -rf "$INSTALL_DIR" echo_green "✔ Removed installation directory." fi # Only remove network if WE created it if [ "$CREATED_NETWORK" = true ]; then if sudo docker network inspect zensailor-net >/dev/null 2>&1; then echo_yellow "Removing zensailor-net network..." sudo docker network rm zensailor-net 2>/dev/null || true echo_green "✔ Removed network." fi fi echo_bold "${RED}Rollback complete. Please fix the issue and try again.${RESET}" } # Set trap to run cleanup on any error or script exit with non-zero status trap 'cleanup_on_failure' ERR command_exists() { command -v "$1" >/dev/null 2>&1 } generate_secret() { openssl rand -hex 16 } echo_bold() { echo "${BOLD}$1${RESET}"; } echo_green() { echo "${GREEN}$1${RESET}"; } echo_yellow() { echo "${YELLOW}$1${RESET}"; } echo_cyan() { echo "${CYAN}$1${RESET}"; } echo_bold "${CYAN}⛵ ZenSailor Production Installer${RESET}" echo "This script will set up a production-ready ZenSailor instance in /opt/zensailor." echo echo_yellow "This script requires sudo privileges to install software and manage files in /opt." sudo -v echo echo_yellow "STEP 1: Checking for dependencies..." if ! command_exists docker; then echo_yellow "Docker is not installed. Installing Docker automatically..." if curl -fsSL https://get.docker.com -o get-docker.sh; then sudo sh get-docker.sh rm -f get-docker.sh echo_green "✔ Docker installed successfully." else echo_bold "${RED}Error: Failed to download Docker installer.${RESET}" exit 1 fi else echo_green "✔ Docker is already installed." fi if ! docker compose version >/dev/null 2>&1; then echo_yellow "Docker Compose V2 not found. Attempting to install plugin..." sudo apt-get update 2>/dev/null || true sudo apt-get install -y docker-compose-plugin 2>/dev/null || \ sudo yum install -y docker-compose-plugin 2>/dev/null || \ echo_bold "${YELLOW}Warning: Could not auto-install Docker Compose. Please check manually.${RESET}" else echo_green "✔ Docker Compose detected." fi for cmd in curl tar openssl getent; do if ! command_exists "$cmd"; then echo_yellow "Installing missing dependency: $cmd..." if command_exists apt-get; then sudo apt-get update -qq && sudo apt-get install -y -qq "$cmd" elif command_exists yum; then sudo yum install -y -q "$cmd" elif command_exists apk; then sudo apk add -q "$cmd" else echo "${RED}Error: Dependency '$cmd' is missing and could not be auto-installed.${RESET}" exit 1 fi fi done echo_green "✔ All dependencies are satisfied." echo echo_yellow "STEP 2: Please provide some configuration details:" DOMAIN_NAME="" ACME_EMAIL="" while [[ -z "$DOMAIN_NAME" || ! "$DOMAIN_NAME" =~ ^[a-zA-Z0-9.-]+$ ]]; do read -r -p "${BOLD}Enter the public domain for ZenSailor (e.g., zensailor.mycompany.com): ${RESET}" DOMAIN_NAME /dev/null 2>&1; then echo_bold "${RED}WARNING: An existing database volume 'zensailor-db-data-prod' was found.${RESET}" echo "If you are reinstalling, the new random password will NOT match the old database password." echo "This will cause 'Authentication failed' errors." echo read -r -p "Do you want to DELETE the existing database volume to start fresh? (y/N) " DELETE_VOL "$INSTALL_DIR/nats-config/nats.conf" << EOF port: 4222 net: "0.0.0.0" # Authentication - credentials from environment authorization { user: \$NATS_USER password: \$NATS_PASS } websocket { port: 9222 no_tls: true } EOF echo_green "✔ NATS configured." echo if [ "$TLS_MODE" = "selfsigned" ]; then echo_yellow "Generating self-signed certificates..." sudo mkdir -p "$INSTALL_DIR/traefik/certs" # Generate cert using openssl directly to avoid external script dependency if possible, # but we can also copy the helper if needed. simpler to just run openssl here. sudo openssl req -new -newkey rsa:4096 -days 3650 -nodes -x509 \ -subj "/C=US/ST=State/L=City/O=ZenSailor/CN=$DOMAIN_NAME" \ -keyout "$INSTALL_DIR/traefik/certs/key.pem" \ -out "$INSTALL_DIR/traefik/certs/cert.pem" 2>/dev/null # Create the tls.yml config sudo mkdir -p "$INSTALL_DIR/traefik-config/dynamic" cat > "$INSTALL_DIR/traefik-config/dynamic/tls.yml" << EOF tls: certificates: - certFile: /certs/cert.pem keyFile: /certs/key.pem EOF echo_green "✔ Self-signed certificates generated." fi echo echo_yellow "STEP 4: Generating production .env file with secure secrets..." POSTGRES_USER="zensailor" POSTGRES_DB="zensailor" POSTGRES_PASSWORD=$(generate_secret) JWT_SECRET=$(generate_secret)$(generate_secret) ENCRYPTION_KEY=$(generate_secret) APP_URL="https://${DOMAIN_NAME}" REGISTRY_URL="zensailor-registry-prod:5000" UPDATES_REGISTRY_URL="https://registry-1.docker.io" if [ -S /var/run/docker.sock ]; then # Try stat with -c (GNU) or -f (BSD/Mac) if stat -c '%g' /var/run/docker.sock >/dev/null 2>&1; then DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) else # Fallback for systems where stat syntax differs DOCKER_GID=$(ls -ln /var/run/docker.sock | awk '{print $4}') fi else # Try getting from group file as fallback if command_exists getent; then DOCKER_GID=$(getent group docker | cut -d: -f3) fi fi if [ -z "$DOCKER_GID" ] || ! [[ "$DOCKER_GID" =~ ^[0-9]+$ ]]; then echo_yellow "${RED}Warning: Could not determine valid Docker Group ID. Defaulting to 0 (root).${RESET}" DOCKER_GID=0 fi # Ensure network exists if ! sudo docker network inspect zensailor-net >/dev/null 2>&1; then echo_yellow "Creating 'zensailor-net' network..." sudo docker network create zensailor-net CREATED_NETWORK=true fi echo "Detected Docker GID: ${DOCKER_GID}" cat > "$INSTALL_DIR/.env" << EOF IMAGE_TAG=${ZENSAILOR_TAG} POSTGRES_DB=${POSTGRES_DB} POSTGRES_USER=${POSTGRES_USER} POSTGRES_PASSWORD=${POSTGRES_PASSWORD} DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@zensailor-postgres-prod:5432/${POSTGRES_DB}?schema=public APP_URL=${APP_URL} NEXT_PUBLIC_APP_URL=${APP_URL} JWT_SECRET=${JWT_SECRET} JWT_EXPIRES_IN_DAYS=7 ENCRYPTION_KEY=${ENCRYPTION_KEY} REGISTRY_URL=${REGISTRY_URL} UPDATES_REGISTRY_URL=${UPDATES_REGISTRY_URL} DIRECT_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@zensailor-postgres-prod:5432/${POSTGRES_DB}?schema=public PREMIUM_EMAILS= NATS_URL=nats://zensailor-nats-prod:4222 NATS_USER=${NATS_USER} NATS_PASS=${NATS_PASS} ACME_EMAIL=${ACME_EMAIL} ACTIVE_COLOR=blue DOCKER_GID=${DOCKER_GID} TLS_MODE=${TLS_MODE} EOF echo_green "✔ .env file created successfully." echo echo_yellow "STEP 5: Setting up initial Traefik configuration..." cd "$INSTALL_DIR" mkdir -p traefik-config/dynamic # Determine TLS config TLS_LINE=' certResolver: "letsencrypt"' if [ "$TLS_MODE" = "selfsigned" ]; then TLS_LINE=' {}' fi cat > traefik-config/dynamic/zensailor-router.yml << EOF http: services: zensailor-dashboard: loadBalancer: servers: - url: "http://zensailor-control-plane-prod-blue:4500" zensailor-registry: loadBalancer: servers: - url: "http://zensailor-registry-prod:5000" zensailor-nats-ws: loadBalancer: servers: - url: "http://zensailor-nats-prod:9222" routers: zensailor-dashboard: rule: "Host(\`${DOMAIN_NAME}\`)" entryPoints: - websecure service: "zensailor-dashboard" middlewares: - security-headers tls: ${TLS_LINE} zensailor-dashboard-http: rule: "Host(\`${DOMAIN_NAME}\`)" entryPoints: - web middlewares: - https-redirect service: "zensailor-dashboard" zensailor-registry-https: rule: "Host(\`registry.${DOMAIN_NAME}\`)" entryPoints: - websecure service: "zensailor-registry" middlewares: - security-headers tls: ${TLS_LINE} zensailor-registry-http: rule: "Host(\`registry.${DOMAIN_NAME}\`)" entryPoints: - web middlewares: - https-redirect service: "zensailor-registry" zensailor-nats-ws-https: rule: "Host(\`nats.${DOMAIN_NAME}\`)" entryPoints: - websecure service: "zensailor-nats-ws" middlewares: - security-headers tls: ${TLS_LINE} zensailor-nats-ws-http: rule: "Host(\`nats.${DOMAIN_NAME}\`)" entryPoints: - web middlewares: - https-redirect service: "zensailor-nats-ws" middlewares: https-redirect: redirectScheme: scheme: https permanent: true security-headers: headers: frameDeny: true contentTypeNosniff: true browserXssFilter: true forceSTSHeader: true stsIncludeSubdomains: true stsPreload: true stsSeconds: 31536000 tcp: services: zensailor-postgres: loadBalancer: servers: - address: "zensailor-postgres-prod:5432" routers: zensailor-postgres-sni: rule: "HostSNI(\`db.${DOMAIN_NAME}\`)" entryPoints: - websecure service: "zensailor-postgres" tls: ${TLS_LINE} EOF echo_green "✔ Initial router created, pointing to 'blue' deployment." echo echo_yellow "STEP 6: Starting the ZenSailor production stack..." echo_yellow "Pulling required Docker images... (This may take a moment)" sudo docker compose -f docker-compose.prod.yml pull echo_yellow "Starting ZenSailor services..." sudo docker compose -f docker-compose.prod.yml up -d postgres nats traefik registry agent control-plane-blue # Mark installation as complete - prevents rollback on successful exit INSTALLATION_COMPLETE=true echo echo_green "⛵ ZenSailor installation is complete! ⛵" echo echo "Your ZenSailor dashboard should be available shortly at:" echo " ${BOLD}${CYAN}${APP_URL}${RESET}" echo echo "It may take a minute for the SSL certificate to be issued." echo "The first user to sign up will become the platform administrator." echo echo "To manage your installation, navigate to the '${BOLD}${INSTALL_DIR}${RESET}' directory." echo " - View logs: ${BOLD}cd ${INSTALL_DIR} && docker compose -f docker-compose.prod.yml logs -f${RESET}" echo " - Stop: ${BOLD}cd ${INSTALL_DIR} && docker compose -f docker-compose.prod.yml down${RESET}"