#!/bin/bash set -euo pipefail # Complete deployment script: build and push to server # Usage: ./scripts/deploy.sh [server_user@server_host] [deployment_path] [platform] # Example: ./scripts/deploy.sh user@example.com /home/user/fromchat linux/arm64 # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' MAGENTA='\033[0;35m' NC='\033[0m' # No Color BOLD='\033[1m' # Helper functions info() { echo -e "${BLUE}ℹ${NC} $1"; } success() { echo -e "${GREEN}✓${NC} $1"; } warning() { echo -e "${YELLOW}⚠${NC} $1"; } error() { echo -e "${RED}✗${NC} $1"; } step() { echo -e "${CYAN}${BOLD}→${NC} ${BOLD}$1${NC}"; } substep() { if [ "${2:-}" = "-n" ]; then echo -n -e " ${GREEN}•${NC} $1" else echo -e " ${GREEN}•${NC} $1" fi } echo -e "${MAGENTA}${BOLD}🚀 Deployment${NC}\n" read_password() { local password="" local char local old_stty old_stty=$(stty -g 2>/dev/null) stty -echo 2>/dev/null while IFS= read -rs -n 1 char; do if [ -z "$char" ]; then break fi if [ "$char" = $'\177' ] || [ "$char" = $'\b' ]; then if [ ${#password} -gt 0 ]; then password="${password%?}" printf "\b \b" >&2 fi else password+="$char" printf "*" >&2 fi done stty "$old_stty" 2>/dev/null echo "" >&2 echo "$password" } SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" DEPLOYMENT_DIR="$PROJECT_ROOT/deployment" ENV_FILE="$DEPLOYMENT_DIR/.env" INPUT_HASH_TOOL="$SCRIPT_DIR/docker_inputs_hash.py" LOCAL_CACHE_ROOT="$PROJECT_ROOT/.deploy-cache" LOCAL_IMAGE_CACHE_DIR="$LOCAL_CACHE_ROOT/images" # Ensure arrays exist even when build is skipped (nounset-safe). declare -a BUILT_IMAGES # Load .env file if it exists if [ -f "$ENV_FILE" ]; then # Export variables from .env file (ignore comments and empty lines) set -a while IFS= read -r line || [ -n "$line" ]; do # Skip comments and empty lines case "$line" in \#*|'') continue ;; *) # Export the variable export "$line" 2>/dev/null || true ;; esac done < "$ENV_FILE" set +a fi # Read server from environment variable (from .env), command line argument, or fallback SERVER="${1:-${DEPLOYMENT_SERVER:-}}" REPO_NAME="FromChat" DEPLOY_PATH="~/actions-runner/_work/$REPO_NAME/$REPO_NAME" PLATFORM="linux/arm64" # Prefer plain `docker build` when target arch matches host arch. # This avoids Docker Desktop buildx export/load issues and is faster for same-arch builds. HOST_ARCH_RAW="$(uname -m 2>/dev/null || echo "")" case "$HOST_ARCH_RAW" in arm64|aarch64) HOST_ARCH="arm64" ;; x86_64|amd64) HOST_ARCH="amd64" ;; *) HOST_ARCH="$HOST_ARCH_RAW" ;; esac PLATFORM_ARCH="${PLATFORM##*/}" USE_DOCKER_BUILD=false if [ -n "$HOST_ARCH" ] && [ "$HOST_ARCH" = "$PLATFORM_ARCH" ]; then USE_DOCKER_BUILD=true fi sanitize_ref() { # Replace characters that are problematic for file paths. # Example: "foo/bar:baz" -> "foo_bar__baz" local s="$1" s="${s//\//_}" s="${s//:/__}" s="${s//@/__at__}" echo "$s" } read_file_if_exists() { local p="$1" if [ -f "$p" ]; then cat "$p" fi } # Docker's top-level image Id can differ for the same layers (OCI index vs image, BuildKit attestations). # Compare a fingerprint of the final layer stack instead. get_local_image_layer_fp() { local img="$1" docker image inspect -f '{{json .RootFS.Layers}}' "$img" 2>/dev/null \ | openssl dgst -sha256 2>/dev/null | awk '{print $2}' || true } compute_inputs_hash() { local context="$1" local dockerfile="$2" python3 "$INPUT_HASH_TOOL" --context "$context" --dockerfile "$dockerfile" } # Check if server is provided if [ -z "$SERVER" ]; then error "Server not specified. Usage: $0 [user@host] [deployment_path] [platform]" echo " Or set DEPLOYMENT_SERVER in $ENV_FILE or as an environment variable" echo "" echo "Example:" echo " $0 user@example.com /home/user/fromchat linux/arm64" echo " Or add to $ENV_FILE: DEPLOYMENT_SERVER=user@example.com" echo " Or: DEPLOYMENT_SERVER=user@example.com $0" exit 1 fi # ============================================================================ # SSH AUTHENTICATION # ============================================================================ step "Authentication" SSH_KEY_FILE="$HOME/.ssh/id_rsa" SSH_KEY_PUB_FILE="$SSH_KEY_FILE.pub" # Ensure ssh-agent is running if [ -z "$SSH_AUTH_SOCK" ]; then eval "$(ssh-agent -s)" > /dev/null 2>&1 fi # Check if SSH key exists if [ ! -f "$SSH_KEY_FILE" ]; then error "SSH key not found at $SSH_KEY_FILE" echo " Please generate an SSH key pair first:" echo " ssh-keygen -t rsa -b 4096 -C 'your_email@example.com'" exit 1 fi # Add SSH key to agent if not already loaded KEY_LOADED=false if ssh-add -l > /dev/null 2>&1; then # Check if this specific key is loaded by trying to match the public key KEY_FINGERPRINT=$(ssh-keygen -lf "$SSH_KEY_FILE" 2>/dev/null | awk '{print $2}') if [ -n "$KEY_FINGERPRINT" ] && ssh-add -l 2>/dev/null | grep -q "$KEY_FINGERPRINT"; then KEY_LOADED=true fi fi if [ "$KEY_LOADED" = false ]; then substep "Adding SSH key to agent..." if ! ssh-add "$SSH_KEY_FILE" 2>/dev/null; then error "Failed to add SSH key to agent. Check your key passphrase." exit 1 fi fi # Require key-based SSH auth; do not attempt to copy keys automatically. if ! ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$SERVER" "echo 'SSH key works'" >/dev/null 2>&1; then error "SSH key authentication failed for $SERVER" echo " Copy your public key to the server, then re-run deploy:" echo " ssh-copy-id -i \"$SSH_KEY_PUB_FILE\" \"$SERVER\"" echo "" echo " Or manually append this key to ~/.ssh/authorized_keys on the server:" echo " $(cat "$SSH_KEY_PUB_FILE")" exit 1 fi # ============================================================================ # SUDO AUTHENTICATION # ============================================================================ SUDO_PASSWORD="" # Prompt for sudo password (optional; leave blank for passwordless sudo) if [ -z "$SUDO_PASSWORD" ]; then while true; do substep "Sudo password: " -n SUDO_PASSWORD=$(read_password) if [ -z "$SUDO_PASSWORD" ]; then warning "No password provided - assuming passwordless sudo" break fi if echo "$SUDO_PASSWORD" | ssh "$SERVER" "sudo -S -v" > /dev/null 2>&1; then export SUDO_PASSWORD break else echo -n " " && error "Invalid password, please try again" fi done fi # ============================================================================ # BUILD PHASE # ============================================================================ echo -e "\n${MAGENTA}${BOLD}🔨 Building Docker images${NC}\n" # Determine project name if [ -n "$SERVER" ]; then COMPOSE_DIR=$(ssh "$SERVER" "dirname $DEPLOY_PATH/deployment/docker-compose.yml" 2>/dev/null || echo "$DEPLOY_PATH/deployment") PROJECT_NAME=$(ssh "$SERVER" "basename $COMPOSE_DIR" 2>/dev/null || echo "deployment") else PROJECT_NAME=$(basename "$DEPLOYMENT_DIR") fi # Check if Docker daemon is running check_docker_daemon() { docker info > /dev/null 2>&1 } # Start Docker Desktop start_docker_desktop() { substep "Starting Docker Desktop..." if ! docker desktop start > /dev/null 2>&1; then return 1 fi # Wait for Docker to be ready (max 60 seconds) substep "Waiting for Docker to start..." -n local max_wait=60 local waited=0 while [ $waited -lt $max_wait ]; do if check_docker_daemon; then echo "" return 0 fi sleep 2 waited=$((waited + 2)) echo -n "." done echo "" return 1 } # Check Docker daemon if ! check_docker_daemon; then warning "Docker daemon is not running" if ! start_docker_desktop; then error "Failed to start Docker Desktop. Please start it manually and try again." fi fi if [ "$USE_DOCKER_BUILD" = false ]; then # Check buildx if ! docker buildx version > /dev/null 2>&1; then error "Docker buildx not available. Install Docker Desktop." fi # Setup buildx builder step "Setting up buildx builder" BUILDER_NAME="fromchat-builder" BUILDER_EXISTS=false if docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then BUILDER_EXISTS=true if ! docker buildx use "$BUILDER_NAME" > /dev/null 2>&1; then substep "Recreating builder..." docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true BUILDER_EXISTS=false elif ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then substep "Recreating builder (inspection failed)..." docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true BUILDER_EXISTS=false fi fi if [ "$BUILDER_EXISTS" = false ]; then substep "Creating builder with persistent cache..." docker buildx create \ --name "$BUILDER_NAME" \ --driver docker-container \ --driver-opt image=moby/buildkit:latest \ --use \ --bootstrap > /dev/null 2>&1 fi docker buildx use "$BUILDER_NAME" > /dev/null 2>&1 fi # Detect services step "Detecting services" cd "$DEPLOYMENT_DIR" # Include profile-only services (e.g. caddy) so their images are built and pushed; otherwise prod keeps a stale Caddy image and ignores Caddyfile updates from rsync. export COMPOSE_PROFILES=production SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null) if [ -z "$SERVICES" ]; then error "No services found in docker-compose.yml" fi if ! command -v jq >/dev/null 2>&1; then error "jq is required for deploy (e.g. brew install jq / sudo apt install jq)" exit 1 fi if ! COMPOSE_JSON=$(docker compose -f docker-compose.yml config --format json 2>/dev/null); then error "docker compose config --format json failed (needs Docker Compose v2.10+)" exit 1 fi BUILT_IMAGES=() PUSHABLE_IMAGES=() PUSHABLE_SERVICES=() PUSHABLE_SERVICE_DOCKERFILES=() PUSHABLE_SERVICE_CONTEXTS=() PUSHABLE_SERVICE_TARGETS=() PUSHABLE_IMAGE_INPUT_HASHES=() for SERVICE in $SERVICES; do if ! jq -e --arg s "$SERVICE" '(.services[$s].build // false) | type == "object"' <<< "$COMPOSE_JSON" >/dev/null 2>&1; then continue fi IMAGE_TAG="${PROJECT_NAME}-${SERVICE}:latest" DOCKERFILE_REL=$(jq -r --arg s "$SERVICE" '.services[$s].build.dockerfile // empty' <<< "$COMPOSE_JSON") CONTEXT_REL=$(jq -r --arg s "$SERVICE" '.services[$s].build.context // empty' <<< "$COMPOSE_JSON") # Multi-stage deployment/Dockerfile: without --target, the final stage (file_storage) is always tagged. BUILD_TARGET=$(jq -r --arg s "$SERVICE" '.services[$s].build.target // empty' <<< "$COMPOSE_JSON") if [ -z "$CONTEXT_REL" ]; then CONTEXT_REL=".." fi if [[ "$CONTEXT_REL" == ".." ]]; then BUILD_CONTEXT="$PROJECT_ROOT" elif [[ "$CONTEXT_REL" == /* ]]; then BUILD_CONTEXT="$CONTEXT_REL" else BUILD_CONTEXT="$DEPLOYMENT_DIR/$CONTEXT_REL" fi if [ -n "$DOCKERFILE_REL" ]; then if [[ "$DOCKERFILE_REL" == /* ]]; then DOCKERFILE="$DOCKERFILE_REL" else if [[ "$CONTEXT_REL" == ".." ]] || [[ "$BUILD_CONTEXT" == "$PROJECT_ROOT" ]]; then DOCKERFILE="$PROJECT_ROOT/$DOCKERFILE_REL" else DOCKERFILE="$BUILD_CONTEXT/$DOCKERFILE_REL" fi fi else if [ -f "$DEPLOYMENT_DIR/Dockerfile.$SERVICE" ]; then DOCKERFILE="$DEPLOYMENT_DIR/Dockerfile.$SERVICE" elif [ -f "$DEPLOYMENT_DIR/$SERVICE/Dockerfile" ]; then DOCKERFILE="$DEPLOYMENT_DIR/$SERVICE/Dockerfile" else error "Could not determine Dockerfile for $SERVICE" fi fi if [ ! -f "$INPUT_HASH_TOOL" ]; then error "Missing $INPUT_HASH_TOOL (needed for dependency hashing)" exit 1 fi INPUT_HASH=$(compute_inputs_hash "$BUILD_CONTEXT" "$DOCKERFILE") PUSHABLE_SERVICES+=("$SERVICE") PUSHABLE_IMAGES+=("$IMAGE_TAG") PUSHABLE_SERVICE_DOCKERFILES+=("$DOCKERFILE") PUSHABLE_SERVICE_CONTEXTS+=("$BUILD_CONTEXT") PUSHABLE_SERVICE_TARGETS+=("$BUILD_TARGET") PUSHABLE_IMAGE_INPUT_HASHES+=("$INPUT_HASH") done mkdir -p "$LOCAL_IMAGE_CACHE_DIR" SERVICES_TO_BUILD=() IMAGES_TO_BUILD=() INPUT_HASHES_TO_BUILD=() DOCKERFILES_TO_BUILD=() CONTEXTS_TO_BUILD=() TARGETS_TO_BUILD=() for i in "${!PUSHABLE_SERVICES[@]}"; do SERVICE="${PUSHABLE_SERVICES[$i]}" IMAGE_TAG="${PUSHABLE_IMAGES[$i]}" INPUT_HASH="${PUSHABLE_IMAGE_INPUT_HASHES[$i]}" DOCKERFILE="${PUSHABLE_SERVICE_DOCKERFILES[$i]}" BUILD_CONTEXT="${PUSHABLE_SERVICE_CONTEXTS[$i]}" BUILD_TARGET="${PUSHABLE_SERVICE_TARGETS[$i]}" CACHE_KEY="$(sanitize_ref "$IMAGE_TAG")" CACHE_FILE="$LOCAL_IMAGE_CACHE_DIR/$CACHE_KEY/input.sha256" PREV_HASH="$(read_file_if_exists "$CACHE_FILE")" LOCAL_FP="$(get_local_image_layer_fp "$IMAGE_TAG")" if [ -n "$PREV_HASH" ] && [ "$PREV_HASH" = "$INPUT_HASH" ] && [ -n "$LOCAL_FP" ]; then continue fi SERVICES_TO_BUILD+=("$SERVICE") IMAGES_TO_BUILD+=("$IMAGE_TAG") INPUT_HASHES_TO_BUILD+=("$INPUT_HASH") DOCKERFILES_TO_BUILD+=("$DOCKERFILE") CONTEXTS_TO_BUILD+=("$BUILD_CONTEXT") TARGETS_TO_BUILD+=("$BUILD_TARGET") done if [ ${#SERVICES_TO_BUILD[@]} -eq 0 ]; then success "Build skipped (no Docker inputs changed)" else step "Building ${#SERVICES_TO_BUILD[@]} service(s)" if [ "$USE_DOCKER_BUILD" = true ]; then # Same-arch fast path: build with Docker Compose (requested). # Ensure the project name is stable so the resulting tags match our expectations. COMPOSE_PROJECT_NAME="$PROJECT_NAME" docker compose -f docker-compose.yml --profile production build "${SERVICES_TO_BUILD[@]}" else for i in "${!SERVICES_TO_BUILD[@]}"; do SERVICE="${SERVICES_TO_BUILD[$i]}" IMAGE_TAG="${IMAGES_TO_BUILD[$i]}" DOCKERFILE="${DOCKERFILES_TO_BUILD[$i]}" BUILD_CONTEXT="${CONTEXTS_TO_BUILD[$i]}" BUILD_TARGET="${TARGETS_TO_BUILD[$i]}" substep "Building ${CYAN}$SERVICE${NC} -> ${CYAN}$IMAGE_TAG${NC}..." # On macOS Docker Desktop, --load can hang for a long time at "sending tarball". # Use the docker exporter explicitly to load into the local Docker daemon. BUILDX_ARGS=(buildx build --platform "$PLATFORM" --file "$DOCKERFILE" --tag "$IMAGE_TAG" --output=type=docker --provenance=false --sbom=false) if [ -n "$BUILD_TARGET" ]; then BUILDX_ARGS+=(--target "$BUILD_TARGET") fi BUILDX_ARGS+=("$BUILD_CONTEXT") if ! docker "${BUILDX_ARGS[@]}"; then error "Build failed for $SERVICE" exit 1 fi done fi for i in "${!SERVICES_TO_BUILD[@]}"; do IMAGE_TAG="${IMAGES_TO_BUILD[$i]}" INPUT_HASH="${INPUT_HASHES_TO_BUILD[$i]}" CACHE_KEY="$(sanitize_ref "$IMAGE_TAG")" mkdir -p "$LOCAL_IMAGE_CACHE_DIR/$CACHE_KEY" printf "%s" "$INPUT_HASH" > "$LOCAL_IMAGE_CACHE_DIR/$CACHE_KEY/input.sha256" BUILT_IMAGES+=("$IMAGE_TAG") done success "Build complete! ${#BUILT_IMAGES[@]} image(s) built" fi # ============================================================================ # DEPLOY PHASE # ============================================================================ echo -e "\n${MAGENTA}${BOLD}🚀 Deploying to ${SERVER}${NC}\n" # Check docker pussh if ! docker pussh --help > /dev/null 2>&1; then error "docker pussh plugin not installed" echo " Install: npm run install:pussh" fi # Detect images based on docker-compose.yml (prefer explicit `image:` entries; fall back to built tags) cd "$DEPLOYMENT_DIR" COMPOSE_SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null || true) PUSH_IMAGES=() EXTERNAL_IMAGES=() for S in $COMPOSE_SERVICES; do IMAGE_FROM_COMPOSE=$(jq -r --arg s "$S" '.services[$s].image // empty' <<< "$COMPOSE_JSON") HAS_BUILD=$(jq -r --arg s "$S" '(.services[$s].build // empty) | (if . == "" then "" else "yes" end)' <<< "$COMPOSE_JSON") if [ -n "$IMAGE_FROM_COMPOSE" ]; then # If it has an explicit image and no build section, it likely won't exist locally (and doesn't need pussh). if [ -z "$HAS_BUILD" ]; then EXTERNAL_IMAGES+=("$IMAGE_FROM_COMPOSE") else # If a service has both build+image, treat it as a built image (pussh). PUSH_IMAGES+=("$IMAGE_FROM_COMPOSE") fi else # If service has a build section (we built it above), use the tag pattern used during build TAG="${PROJECT_NAME}-${S}:latest" # Only include the tag if the image exists locally (avoid pushing unrelated images) if docker images --format "{{.Repository}}:{{.Tag}}" | grep -q "^${TAG}$"; then PUSH_IMAGES+=("$TAG") fi fi done # Deduplicate while preserving order if [ ${#PUSH_IMAGES[@]} -gt 0 ]; then PUSH_IMAGES=($(printf "%s\n" "${PUSH_IMAGES[@]}" | awk '!seen[$0]++')) fi if [ ${#EXTERNAL_IMAGES[@]} -gt 0 ]; then EXTERNAL_IMAGES=($(printf "%s\n" "${EXTERNAL_IMAGES[@]}" | awk '!seen[$0]++')) fi # Verify that all built images are among the detected images to be pushed. # This prevents accidentally pushing unrelated images. BUILT_COUNT=${#BUILT_IMAGES[@]} MATCHING_BUILT=0 MISSING_FROM_DETECTED=() # Bash 3.2 + set -u: "${arr[@]}" on an empty array can error; guard with length. if ((${#BUILT_IMAGES[@]} > 0)); then for BI in "${BUILT_IMAGES[@]}"; do found=false for DI in "${PUSH_IMAGES[@]}"; do if [ "$BI" = "$DI" ]; then found=true break fi done if [ "$found" = true ]; then MATCHING_BUILT=$((MATCHING_BUILT + 1)) else MISSING_FROM_DETECTED+=("$BI") fi done fi # Also list push images that weren't built locally (these are likely prebuilt local images) NOT_BUILT_DETECTED=() for DI in "${PUSH_IMAGES[@]}"; do built=false if ((${#BUILT_IMAGES[@]} > 0)); then for BI in "${BUILT_IMAGES[@]}"; do if [ "$DI" = "$BI" ]; then built=true break fi done fi if [ "$built" = false ]; then NOT_BUILT_DETECTED+=("$DI") fi done if [ "$BUILT_COUNT" -ne "$MATCHING_BUILT" ]; then error "Mismatch between built images (${BUILT_COUNT}) and detected built images (${MATCHING_BUILT})." if [ ${#MISSING_FROM_DETECTED[@]} -gt 0 ]; then echo " Built but not detected: ${MISSING_FROM_DETECTED[*]}" fi if [ ${#NOT_BUILT_DETECTED[@]} -gt 0 ]; then echo " Detected but not built (external images): ${NOT_BUILT_DETECTED[*]}" fi echo "Aborting to avoid pushing incorrect images." exit 1 fi if [ ${#PUSH_IMAGES[@]} -eq 0 ] && [ ${#EXTERNAL_IMAGES[@]} -eq 0 ]; then error "No images found in docker-compose.yml or built locally for project ${PROJECT_NAME}" fi # Transfer images — only what was rebuilt this run (BUILT_IMAGES). No remote hash/fingerprint checks. step "Transferring images" IMAGES_TO_PUSH=() if ((${#BUILT_IMAGES[@]} > 0)); then for PI in "${PUSH_IMAGES[@]}"; do for BI in "${BUILT_IMAGES[@]}"; do if [ "$PI" = "$BI" ]; then IMAGES_TO_PUSH+=("$PI") break fi done done fi if [ ${#IMAGES_TO_PUSH[@]} -eq 0 ]; then success "Skipping image push (nothing was rebuilt this run)" else UNREGISTRY_IMAGE="ghcr.io/psviderski/unregistry" if ! ssh "$SERVER" "sudo docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^${UNREGISTRY_IMAGE}$'" 2>/dev/null; then substep "Pulling unregistry image (one-time setup)..." if [ -n "${SUDO_PASSWORD:-}" ]; then ssh "$SERVER" "echo '$SUDO_PASSWORD' | sudo -S -p '' docker pull ${UNREGISTRY_IMAGE}" else ssh "$SERVER" "sudo docker pull ${UNREGISTRY_IMAGE}" fi fi for IMAGE in "${IMAGES_TO_PUSH[@]}"; do substep "Pushing ${CYAN}$IMAGE${NC}..." if ! docker pussh "$IMAGE" "$SERVER"; then echo -e " ${RED}✗${NC} Failed to push ${CYAN}$IMAGE${NC}" echo "" exit 1 fi echo "" done fi # Pull external images directly on the server (no pussh) if [ ${#EXTERNAL_IMAGES[@]} -gt 0 ]; then step "Pulling external images on server" for IMAGE in "${EXTERNAL_IMAGES[@]}"; do substep "Pulling ${CYAN}$IMAGE${NC}..." QIMG=$(printf '%q' "$IMAGE") if [ -n "${SUDO_PASSWORD:-}" ]; then if ssh "$SERVER" "echo '$SUDO_PASSWORD' | sudo -S -p '' docker pull $QIMG"; then echo "" else echo -e " ${RED}✗${NC} Failed to pull ${CYAN}$IMAGE${NC} on server" echo "" exit 1 fi elif ssh "$SERVER" "sudo docker pull $QIMG"; then echo "" else echo -e " ${RED}✗${NC} Failed to pull ${CYAN}$IMAGE${NC} on server" echo "" exit 1 fi done fi # Transfer files step "Transferring deployment files" # Ensure destination directory exists with proper permissions if [ -n "$SUDO_PASSWORD" ]; then ssh "$SERVER" bash << REMOTE_SUDO_SCRIPT > /dev/null 2>&1 set -e echo '$SUDO_PASSWORD' | sudo -S -p '' mkdir -p $DEPLOY_PATH/deployment $DEPLOY_PATH/backend 2>/dev/null || true echo '$SUDO_PASSWORD' | sudo -S -p '' chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment $DEPLOY_PATH/backend 2>/dev/null || true REMOTE_SUDO_SCRIPT else ssh "$SERVER" "sudo mkdir -p $DEPLOY_PATH/deployment $DEPLOY_PATH/backend && sudo chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment $DEPLOY_PATH/backend" > /dev/null 2>&1 || true fi # Copy deployment directory excluding gitignored files cd "$PROJECT_ROOT" substep "Copying deployment directory..." # Generate exclude file for rsync using git ls-files to list ignored files EXCLUDE_FILE="/tmp/fromchat-rsync-exclude-$$" RSYNC_ERROR="/tmp/fromchat-rsync-error-$$" # Get ignored files in deployment directory and convert to rsync exclude patterns git ls-files --others --ignored --exclude-standard deployment/ 2>/dev/null | \ sed 's|^deployment/||' > "$EXCLUDE_FILE" || true # Use rsync with native --exclude-from option if rsync -avz --delete --exclude-from="$EXCLUDE_FILE" \ "$DEPLOYMENT_DIR/" \ "$SERVER:$DEPLOY_PATH/deployment/" > "$RSYNC_ERROR" 2>&1; then rm -f "$EXCLUDE_FILE" "$RSYNC_ERROR" else echo -e " ${RED}✗${NC} Rsync failed. Error output:" cat "$RSYNC_ERROR" | sed 's/^/ /' rm -f "$EXCLUDE_FILE" "$RSYNC_ERROR" echo -n " " && error "Failed to copy deployment directory" fi # Copy .env.prod to .env on server (bypassing gitignore) if [ -f "$DEPLOYMENT_DIR/.env.prod" ]; then substep "Copying .env.prod to .env..." if ! scp "$DEPLOYMENT_DIR/.env.prod" "$SERVER:$DEPLOY_PATH/deployment/.env" > /dev/null 2>&1; then warning "Failed to copy .env.prod to .env" fi else warning ".env.prod not found in deployment directory" fi # Firebase service account: bind-mounted at runtime (see docker-compose main volumes), never in the image (.dockerignore). # If compose ever ran without this file on the host, Docker may have created a directory at this path — remove it before scp. FIREBASE_CERT="$PROJECT_ROOT/backend/firebase-cert.json" substep "Firebase service account (runtime bind-mount: backend/firebase-cert.json)..." # ~ is not expanded inside variables on the remote shell (e.g. C="$D/..." with D=~/foo checks a bogus path). Resolve to an absolute path. DEPLOY_PATH_ON_SERVER=$(ssh -o BatchMode=yes -o ConnectTimeout=10 "$SERVER" "eval echo $DEPLOY_PATH" 2>/dev/null || true) if [ -z "$DEPLOY_PATH_ON_SERVER" ]; then DEPLOY_PATH_ON_SERVER=$DEPLOY_PATH fi FIREBASE_REMOTE="$DEPLOY_PATH_ON_SERVER/backend/firebase-cert.json" # Docker may have created this path as a root-owned directory; plain rm fails without sudo. if [ -n "$SUDO_PASSWORD" ]; then ssh "$SERVER" bash << REMOTE_FIREBASE_CLEANUP > /dev/null 2>&1 || true set -e D=$DEPLOY_PATH_ON_SERVER C="\$D/backend/firebase-cert.json" mkdir -p "\$D/backend" 2>/dev/null || true if [ -d "\$C" ]; then echo '$SUDO_PASSWORD' | sudo -S -p '' rm -rf "\$C" fi echo '$SUDO_PASSWORD' | sudo -S -p '' chown -R "\$(whoami):\$(whoami)" "\$D/backend" 2>/dev/null || true REMOTE_FIREBASE_CLEANUP else QBASE=$(printf '%q' "$DEPLOY_PATH_ON_SERVER") ssh "$SERVER" "D=$QBASE; C=\"\$D/backend/firebase-cert.json\"; mkdir -p \"\$D/backend\"; if [ -d \"\$C\" ]; then sudo rm -rf \"\$C\" 2>/dev/null || rm -rf \"\$C\"; fi; sudo chown -R \$(whoami):\$(whoami) \"\$D/backend\" 2>/dev/null || true" > /dev/null 2>&1 || true fi while true; do if [ -f "$FIREBASE_CERT" ]; then break fi if [ -d "$FIREBASE_CERT" ]; then echo -e " ${YELLOW}⚠${NC} $FIREBASE_CERT is a directory. Delete it and save the Firebase service account JSON as a file at that exact path." elif [ -e "$FIREBASE_CERT" ]; then echo -e " ${YELLOW}⚠${NC} $FIREBASE_CERT exists but is not a regular file." else echo -e " ${YELLOW}⚠${NC} Missing $FIREBASE_CERT (Firebase service account JSON for FCM)." fi echo -e " ${CYAN}Fix this, then press Enter to check again (Ctrl+C to abort deploy).${NC}" read -r _ done substep "Copying backend/firebase-cert.json..." FIREBASE_SCP_LOG="/tmp/fromchat-firebase-scp-$$.log" if ! scp "$FIREBASE_CERT" "$SERVER:$FIREBASE_REMOTE" >"$FIREBASE_SCP_LOG" 2>&1; then error "Failed to copy firebase-cert.json to server" echo -e " ${YELLOW}Target:${NC} $SERVER:$FIREBASE_REMOTE" >&2 if [ -s "$FIREBASE_SCP_LOG" ]; then sed 's/^/ /' "$FIREBASE_SCP_LOG" >&2 else echo -e " ${YELLOW}(scp produced no output.)${NC}" >&2 fi rm -f "$FIREBASE_SCP_LOG" exit 1 fi rm -f "$FIREBASE_SCP_LOG" ssh "$SERVER" "chmod 600 $(printf '%q' "$FIREBASE_REMOTE") 2>/dev/null || true" if ! ssh "$SERVER" "test -f $(printf '%q' "$FIREBASE_REMOTE")"; then error "Server path is not a regular file after copy: $FIREBASE_REMOTE" exit 1 fi # Deploy on server step "Deploying on server" ssh "$SERVER" SUDO_PASSWORD="$SUDO_PASSWORD" DEPLOY_PATH="${DEPLOY_PATH_ON_SERVER:-$DEPLOY_PATH}" bash << 'REMOTE_SCRIPT' set -e REMOTE_SUDO_PASS="${SUDO_PASSWORD:-}" REMOTE_DEPLOY_PATH="${DEPLOY_PATH:-}" export SUDO_PROMPT="" sudo_cmd() { if [ -n "$REMOTE_SUDO_PASS" ]; then echo "$REMOTE_SUDO_PASS" | sudo -S -p '' "$@" 2>/dev/null else sudo "$@" 2>/dev/null fi } if [ -z "$REMOTE_DEPLOY_PATH" ]; then echo "❌ DEPLOY_PATH is not set" exit 1 fi mkdir -p "$REMOTE_DEPLOY_PATH/deployment" "$REMOTE_DEPLOY_PATH/backend" cd "$REMOTE_DEPLOY_PATH/deployment" if [ ! -f "$REMOTE_DEPLOY_PATH/deployment/.env" ]; then echo "⚠️ Warning: .env file not found" fi if systemctl is-active --quiet fromchat; then sudo_cmd systemctl stop fromchat fi COMPOSE_PROFILES=production docker compose down --remove-orphans > /dev/null 2>&1 || true sudo_cmd cp -f "$REMOTE_DEPLOY_PATH/deployment/fromchat.service" /etc/systemd/system/fromchat.service sudo_cmd systemctl daemon-reload sudo_cmd systemctl restart fromchat sleep 3 if ! systemctl is-active --quiet fromchat; then echo "❌ Service failed to start" sudo_cmd journalctl --no-pager -xeu fromchat -n 30 exit 1 fi REMOTE_SCRIPT echo success "Deployment complete!"