#!/usr/bin/env bash set -euo pipefail action="${1:-}" app_name="${2:-}" archive="${3:-}" mode="${4:-auto}" deploy_root="${5:-/home/jsxq/apps}" health_url="${6:-}" skip_health="${7:-0}" publish_port="${8:-0}" container_port="${9:-0}" if [[ ! "$app_name" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then echo "Invalid application name" >&2 exit 2 fi expected_prefix="/home/${USER}/apps" if [[ "$deploy_root" != "$expected_prefix" ]]; then echo "Refusing deployment outside $expected_prefix" >&2 exit 2 fi app_name="${app_name,,}" project_name="codex-${app_name}" app_root="${deploy_root}/${app_name}" releases_dir="${app_root}/releases" current_link="${app_root}/current" previous_link="${app_root}/previous" find_compose_file() { local directory="$1" local candidate for candidate in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do if [[ -f "${directory}/${candidate}" ]]; then printf '%s\n' "${directory}/${candidate}" return 0 fi done return 1 } compose_up() { local directory="$1" local compose_file compose_file="$(find_compose_file "$directory")" (cd "$directory" && docker compose -p "$project_name" -f "$compose_file" up -d --build --remove-orphans) } compose_down() { local directory="$1" local compose_file compose_file="$(find_compose_file "$directory" || true)" if [[ -z "$compose_file" ]]; then return 0 fi (cd "$directory" && docker compose -p "$project_name" -f "$compose_file" down --remove-orphans) || true } swap_release_links() { local from="$1" local to="$2" ln -sfn "$to" "$current_link" ln -sfn "$from" "$previous_link" } rollback_release() { if [[ ! -L "$previous_link" ]]; then echo "No previous release is available" >&2 return 1 fi local current_release previous_release current_mode previous_mode current_release="" if [[ -L "$current_link" ]]; then current_release="$(readlink -f "$current_link" 2>/dev/null || true)" fi previous_release="$(readlink -f "$previous_link")" current_mode="" if [[ -n "$current_release" && -f "${current_release}/.codex-deploy-mode" ]]; then current_mode="$(cat "${current_release}/.codex-deploy-mode")" fi previous_mode="$(cat "${previous_release}/.codex-deploy-mode")" if [[ "$current_mode" == "compose" && -n "$current_release" ]]; then compose_down "$current_release" elif [[ "$current_mode" == "dockerfile" ]]; then docker rm -f "${project_name}-app" >/dev/null 2>&1 || true fi if [[ "$previous_mode" == "compose" ]]; then compose_up "$previous_release" elif [[ "$previous_mode" == "dockerfile" ]]; then if docker container inspect "${project_name}-previous" >/dev/null 2>&1; then docker rename "${project_name}-previous" "${project_name}-app" docker start "${project_name}-app" >/dev/null else echo "Previous Dockerfile container is unavailable" >&2 return 1 fi else echo "Unknown previous deployment mode: $previous_mode" >&2 return 1 fi swap_release_links "$current_release" "$previous_release" echo "ROLLBACK_STATUS=success" echo "CURRENT_RELEASE=$previous_release" } if [[ "$action" == "rollback" ]]; then rollback_release exit 0 fi if [[ "$action" != "deploy" ]]; then echo "Expected deploy or rollback action" >&2 exit 2 fi if [[ ! -f "$archive" ]]; then echo "Deployment archive not found: $archive" >&2 exit 2 fi command -v docker >/dev/null docker compose version >/dev/null mkdir -p "$releases_dir" timestamp="$(date -u +%Y%m%dT%H%M%SZ)" release_dir="${releases_dir}/${timestamp}" if [[ -e "$release_dir" ]]; then release_dir="${release_dir}-$RANDOM" fi mkdir -p "$release_dir" tar -xzf "$archive" -C "$release_dir" printf '%s\n' "$mode" > "${release_dir}/.codex-deploy-mode" old_release="" if [[ -L "$current_link" ]]; then old_release="$(readlink -f "$current_link" 2>/dev/null || true)" fi old_mode="" if [[ -n "$old_release" ]]; then if [[ -f "${old_release}/.codex-deploy-mode" ]]; then old_mode="$(cat "${old_release}/.codex-deploy-mode")" fi ln -sfn "$old_release" "$previous_link" fi ln -sfn "$release_dir" "$current_link" deploy_failed=0 if [[ "$mode" == "compose" ]]; then if [[ "$old_mode" == "dockerfile" ]] && docker container inspect "${project_name}-app" >/dev/null 2>&1; then docker rm -f "${project_name}-previous" >/dev/null 2>&1 || true docker stop "${project_name}-app" >/dev/null || true docker rename "${project_name}-app" "${project_name}-previous" fi if ! find_compose_file "$release_dir" >/dev/null; then echo "Compose file not found in release" >&2 deploy_failed=1 elif ! compose_up "$release_dir"; then deploy_failed=1 fi elif [[ "$mode" == "dockerfile" ]]; then if [[ "$old_mode" == "compose" && -n "$old_release" ]]; then compose_down "$old_release" fi if [[ ! -f "${release_dir}/Dockerfile" ]]; then echo "Dockerfile not found in release" >&2 deploy_failed=1 else image_name="${project_name}:${timestamp}" if ! (cd "$release_dir" && docker build -t "$image_name" .); then deploy_failed=1 else docker rm -f "${project_name}-previous" >/dev/null 2>&1 || true if docker container inspect "${project_name}-app" >/dev/null 2>&1; then docker stop "${project_name}-app" >/dev/null || true docker rename "${project_name}-app" "${project_name}-previous" fi run_args=(--detach --name "${project_name}-app" --restart unless-stopped) if [[ "$publish_port" != "0" && "$container_port" != "0" ]]; then run_args+=(--publish "${publish_port}:${container_port}") fi if [[ -f "${release_dir}/.env" ]]; then run_args+=(--env-file "${release_dir}/.env") fi if ! docker run "${run_args[@]}" "$image_name"; then deploy_failed=1 fi fi fi else echo "Unsupported deployment mode: $mode" >&2 deploy_failed=1 fi if [[ "$deploy_failed" == "0" && "$skip_health" != "1" ]]; then sleep 3 if [[ -n "$health_url" ]]; then if command -v curl >/dev/null 2>&1; then curl --fail --silent --show-error --max-time 15 "$health_url" >/dev/null || deploy_failed=1 elif command -v wget >/dev/null 2>&1; then wget --quiet --timeout=15 --spider "$health_url" || deploy_failed=1 else echo "No curl or wget is available for health checks" >&2 deploy_failed=1 fi elif [[ "$mode" == "compose" ]]; then compose_file="$(find_compose_file "$release_dir")" (cd "$release_dir" && docker compose -p "$project_name" -f "$compose_file" ps --status running -q | grep -q .) || deploy_failed=1 else [[ "$(docker inspect -f '{{.State.Running}}' "${project_name}-app" 2>/dev/null || true)" == "true" ]] || deploy_failed=1 fi fi if [[ "$deploy_failed" != "0" ]]; then echo "Deployment or health check failed" >&2 if [[ -n "$old_release" && -L "$previous_link" ]]; then rollback_release || true echo "AUTO_ROLLBACK=attempted" >&2 else if [[ "$mode" == "compose" ]]; then compose_down "$release_dir" else docker rm -f "${project_name}-app" >/dev/null 2>&1 || true fi rm -f "$current_link" echo "AUTO_ROLLBACK=unavailable" >&2 fi exit 1 fi echo "DEPLOY_STATUS=success" echo "APP_NAME=$app_name" echo "MODE=$mode" echo "CURRENT_RELEASE=$release_dir" if [[ -n "$health_url" ]]; then echo "HEALTH_URL=$health_url" fi