Spaces:
Paused
Paused
| set -euo pipefail | |
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| DEFAULT_PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" | |
| PROJECT_ROOT="${PROJECT_ROOT:-$DEFAULT_PROJECT_ROOT}" | |
| PROFILE_DIR="${PROFILE_DIR:-$PROJECT_ROOT/auth_profiles/saved}" | |
| DOCKER_ENV_FILE="${DOCKER_ENV_FILE:-$PROJECT_ROOT/docker/.env}" | |
| DEFAULT_IMAGE_NAME="ai-studio-proxy:latest" | |
| COMPOSE_DEFAULT_IMAGE_NAME="docker-ai-studio-proxy:latest" | |
| IMAGE_NAME="${IMAGE_NAME:-$DEFAULT_IMAGE_NAME}" | |
| IMAGE_NAME_SOURCE="default" | |
| CONTAINER_PREFIX="${CONTAINER_PREFIX:-ai-proxy}" | |
| API_KEY="${API_KEY:-sk-dummy}" | |
| SERVER_LOG_LEVEL="${SERVER_LOG_LEVEL:-INFO}" | |
| BASE_API_PORT="${BASE_API_PORT:-2048}" | |
| BASE_STREAM_PORT="${BASE_STREAM_PORT:-3120}" | |
| MEMORY_PER_INSTANCE_GB="${MEMORY_PER_INSTANCE_GB:-1}" | |
| AUTO_CONFIRM="${AUTO_CONFIRM:-false}" | |
| DRY_RUN="${DRY_RUN:-false}" | |
| MOUNT_DOCKER_ENV="${MOUNT_DOCKER_ENV:-true}" | |
| DISABLE_AUTH_ROTATION="${DISABLE_AUTH_ROTATION:-true}" | |
| EXTRA_DOCKER_ARGS="${EXTRA_DOCKER_ARGS:-}" | |
| RUNTIME_STATE_DIR="${RUNTIME_STATE_DIR:-$PROJECT_ROOT/.multi-instance-runtime}" | |
| usage() { | |
| cat <<'EOF' | |
| Usage: | |
| bash scripts/multi-instance-manager/run-multi-instance.sh [options] | |
| Options: | |
| --project-root PATH Path to repository root | |
| --profile-dir PATH Directory with saved auth profiles | |
| --docker-env-file PATH Path to docker/.env file to mount into containers | |
| --image NAME Docker image name to run | |
| --container-prefix PREFIX Prefix for container names | |
| --api-key KEY Shared API key for launched containers | |
| --log-level LEVEL SERVER_LOG_LEVEL value | |
| --base-api-port PORT Starting host port for API service | |
| --base-stream-port PORT Starting host port for stream service | |
| --memory-per-instance-gb N Estimated RAM usage per instance | |
| --auto-confirm Skip interactive confirmation | |
| --dry-run Print actions without running docker | |
| --no-docker-env Do not mount docker/.env into containers | |
| --enable-auth-rotation Allow containers to rotate into other profiles | |
| --help Show this help | |
| Environment variables can also be used: | |
| PROJECT_ROOT PROFILE_DIR DOCKER_ENV_FILE IMAGE_NAME CONTAINER_PREFIX API_KEY | |
| SERVER_LOG_LEVEL BASE_API_PORT BASE_STREAM_PORT MEMORY_PER_INSTANCE_GB | |
| AUTO_CONFIRM DRY_RUN MOUNT_DOCKER_ENV DISABLE_AUTH_ROTATION EXTRA_DOCKER_ARGS | |
| RUNTIME_STATE_DIR | |
| EOF | |
| } | |
| log() { | |
| printf '%s\n' "$*" | |
| } | |
| warn() { | |
| printf '⚠️ %s\n' "$*" >&2 | |
| } | |
| die() { | |
| printf '❌ %s\n' "$*" >&2 | |
| exit 1 | |
| } | |
| command_exists() { | |
| command -v "$1" >/dev/null 2>&1 | |
| } | |
| trim() { | |
| local value="$1" | |
| value="${value#${value%%[![:space:]]*}}" | |
| value="${value%${value##*[![:space:]]}}" | |
| printf '%s' "$value" | |
| } | |
| parse_args() { | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --project-root) | |
| PROJECT_ROOT="$2" | |
| shift 2 | |
| ;; | |
| --profile-dir) | |
| PROFILE_DIR="$2" | |
| shift 2 | |
| ;; | |
| --docker-env-file) | |
| DOCKER_ENV_FILE="$2" | |
| shift 2 | |
| ;; | |
| --image) | |
| IMAGE_NAME="$2" | |
| IMAGE_NAME_SOURCE="cli" | |
| shift 2 | |
| ;; | |
| --container-prefix) | |
| CONTAINER_PREFIX="$2" | |
| shift 2 | |
| ;; | |
| --api-key) | |
| API_KEY="$2" | |
| shift 2 | |
| ;; | |
| --log-level) | |
| SERVER_LOG_LEVEL="$2" | |
| shift 2 | |
| ;; | |
| --base-api-port) | |
| BASE_API_PORT="$2" | |
| shift 2 | |
| ;; | |
| --base-stream-port) | |
| BASE_STREAM_PORT="$2" | |
| shift 2 | |
| ;; | |
| --memory-per-instance-gb) | |
| MEMORY_PER_INSTANCE_GB="$2" | |
| shift 2 | |
| ;; | |
| --auto-confirm) | |
| AUTO_CONFIRM="true" | |
| shift | |
| ;; | |
| --dry-run) | |
| DRY_RUN="true" | |
| shift | |
| ;; | |
| --no-docker-env) | |
| MOUNT_DOCKER_ENV="false" | |
| shift | |
| ;; | |
| --enable-auth-rotation) | |
| DISABLE_AUTH_ROTATION="false" | |
| shift | |
| ;; | |
| --help|-h) | |
| usage | |
| exit 0 | |
| ;; | |
| *) | |
| die "Unknown option: $1" | |
| ;; | |
| esac | |
| done | |
| } | |
| require_integer() { | |
| local name="$1" | |
| local value="$2" | |
| [[ "$value" =~ ^[0-9]+$ ]] || die "$name must be a non-negative integer, got: $value" | |
| } | |
| validate_paths() { | |
| [[ -d "$PROJECT_ROOT" ]] || die "Project root not found: $PROJECT_ROOT" | |
| [[ -d "$PROFILE_DIR" ]] || die "Profile directory not found: $PROFILE_DIR" | |
| if [[ "$MOUNT_DOCKER_ENV" == "true" && ! -f "$DOCKER_ENV_FILE" ]]; then | |
| die "docker env file not found: $DOCKER_ENV_FILE" | |
| fi | |
| mkdir -p "$RUNTIME_STATE_DIR/active" | |
| } | |
| resolve_image_name() { | |
| if docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then | |
| return 0 | |
| fi | |
| if [[ "$IMAGE_NAME_SOURCE" == "default" && "$IMAGE_NAME" == "$DEFAULT_IMAGE_NAME" ]] \ | |
| && docker image inspect "$COMPOSE_DEFAULT_IMAGE_NAME" >/dev/null 2>&1; then | |
| warn "Docker image $DEFAULT_IMAGE_NAME not found; falling back to Compose-built image $COMPOSE_DEFAULT_IMAGE_NAME" | |
| IMAGE_NAME="$COMPOSE_DEFAULT_IMAGE_NAME" | |
| return 0 | |
| fi | |
| die "docker image not found: $IMAGE_NAME" | |
| } | |
| check_docker() { | |
| command_exists docker || die "docker command not found" | |
| docker info >/dev/null 2>&1 || die "docker daemon is not available" | |
| resolve_image_name | |
| } | |
| discover_profiles() { | |
| shopt -s nullglob | |
| PROFILE_FILES=("$PROFILE_DIR"/*.json) | |
| shopt -u nullglob | |
| [[ ${#PROFILE_FILES[@]} -gt 0 ]] || die "No .json auth profiles found in $PROFILE_DIR" | |
| } | |
| print_memory_warning() { | |
| local instance_count="${#PROFILE_FILES[@]}" | |
| local estimated_total_gb=$((instance_count * MEMORY_PER_INSTANCE_GB)) | |
| log "============================================================" | |
| warn "Multi-instance mode launches one isolated Docker container per auth profile." | |
| warn "Each instance may consume about ${MEMORY_PER_INSTANCE_GB} GB RAM or more." | |
| warn "Planned instances: ${instance_count}" | |
| warn "Estimated RAM requirement: ~${estimated_total_gb} GB" | |
| warn "Running too many instances can trigger memory pressure or OOM kills." | |
| log "============================================================" | |
| } | |
| confirm_launch() { | |
| [[ "$AUTO_CONFIRM" == "true" ]] && return 0 | |
| local answer | |
| printf 'Continue launching %s containers? [y/N]: ' "${#PROFILE_FILES[@]}" | |
| read -r answer | |
| answer="$(trim "$answer")" | |
| [[ "$answer" =~ ^([yY][eE][sS]|[yY])$ ]] || die "Launch cancelled by user" | |
| } | |
| port_in_use() { | |
| local port="$1" | |
| if command_exists lsof; then | |
| lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 | |
| return $? | |
| fi | |
| docker ps --format '{{.Ports}}' | grep -E "(^|[ ,])0\.0\.0\.0:${port}->|(^|[ ,])127\.0\.0\.1:${port}->|(^|[ ,])\[::\]:${port}->" >/dev/null 2>&1 | |
| } | |
| container_exists() { | |
| local container_name="$1" | |
| docker ps -a --format '{{.Names}}' | grep -Fx "$container_name" >/dev/null 2>&1 | |
| } | |
| container_uses_port() { | |
| local container_name="$1" | |
| local port="$2" | |
| docker ps --filter "name=^${container_name}$" --format '{{.Ports}}' | grep -E "(^|[ ,])127\.0\.0\.1:${port}->|(^|[ ,])0\.0\.0\.0:${port}->|(^|[ ,])\[::\]:${port}->" >/dev/null 2>&1 | |
| } | |
| check_port_available() { | |
| local port="$1" | |
| local port_role="$2" | |
| local container_name="$3" | |
| if port_in_use "$port"; then | |
| if [[ -n "$container_name" ]] && container_uses_port "$container_name" "$port"; then | |
| return 0 | |
| fi | |
| die "Host ${port_role} port already in use by another listener: $port" | |
| fi | |
| } | |
| sanitize_name() { | |
| local value="$1" | |
| value="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')" | |
| value="${value//[^a-z0-9_.-]/-}" | |
| value="${value#-}" | |
| value="${value%-}" | |
| printf '%s' "$value" | |
| } | |
| stop_existing_container() { | |
| local container_name="$1" | |
| if container_exists "$container_name"; then | |
| log "♻️ Removing existing container: $container_name" | |
| if [[ "$DRY_RUN" != "true" ]]; then | |
| docker rm -f "$container_name" >/dev/null | |
| fi | |
| fi | |
| } | |
| prepare_container_ports() { | |
| local container_name="$1" | |
| local api_port="$2" | |
| local stream_port="$3" | |
| check_port_available "$api_port" "API" "$container_name" | |
| check_port_available "$stream_port" "stream" "$container_name" | |
| stop_existing_container "$container_name" | |
| check_port_available "$api_port" "API" "" | |
| check_port_available "$stream_port" "stream" "" | |
| } | |
| launch_container() { | |
| local profile_path="$1" | |
| local profile_name="$2" | |
| local api_port="$3" | |
| local stream_port="$4" | |
| local container_name="$5" | |
| local container_profile_path="/app/auth_profiles/saved/${profile_name}.json" | |
| local runtime_active_host_path="$RUNTIME_STATE_DIR/active/${container_name}.json" | |
| local container_active_profile_path="/app/auth_profiles/active/${profile_name}.json" | |
| cp "$profile_path" "$runtime_active_host_path" | |
| local -a docker_args=( | |
| run -d | |
| --name "$container_name" | |
| --restart unless-stopped | |
| -p "127.0.0.1:${api_port}:2048" | |
| -p "127.0.0.1:${stream_port}:3120" | |
| -v "$PROJECT_ROOT/auth_profiles:/app/auth_profiles" | |
| -v "$runtime_active_host_path:${container_active_profile_path}:ro" | |
| -e "ACTIVE_AUTH_JSON_PATH=${container_profile_path}" | |
| -e "API_KEY=${API_KEY}" | |
| -e "SERVER_LOG_LEVEL=${SERVER_LOG_LEVEL}" | |
| ) | |
| if [[ "$MOUNT_DOCKER_ENV" == "true" ]]; then | |
| docker_args+=( -v "$DOCKER_ENV_FILE:/app/.env:ro" ) | |
| fi | |
| if [[ "$DISABLE_AUTH_ROTATION" == "true" ]]; then | |
| docker_args+=( | |
| -e "AUTO_AUTH_ROTATION_ON_STARTUP=false" | |
| -e "AUTO_ROTATE_AUTH_PROFILE=false" | |
| ) | |
| fi | |
| if [[ -n "$EXTRA_DOCKER_ARGS" ]]; then | |
| # shellcheck disable=SC2206 | |
| local -a extra_args=( $EXTRA_DOCKER_ARGS ) | |
| docker_args+=( "${extra_args[@]}" ) | |
| fi | |
| docker_args+=( "$IMAGE_NAME" ) | |
| log "------------------------------------------------------------" | |
| log "🚀 Launching profile: $profile_name" | |
| log " Container: $container_name" | |
| log " API port: $api_port" | |
| log " Stream: $stream_port" | |
| log " Saved auth: $container_profile_path" | |
| log " Active mount: $container_active_profile_path" | |
| log " Startup mode: dual-path (ACTIVE_AUTH_JSON_PATH + active-file mount)" | |
| if [[ "$DISABLE_AUTH_ROTATION" == "true" ]]; then | |
| log " Rotation: disabled for strict profile isolation" | |
| fi | |
| if [[ "$DRY_RUN" == "true" ]]; then | |
| printf 'DRY RUN: docker' | |
| printf ' %q' "${docker_args[@]}" | |
| printf '\n' | |
| else | |
| docker "${docker_args[@]}" >/dev/null | |
| fi | |
| } | |
| print_summary() { | |
| log "" | |
| log "✅ Done. Processed profiles: ${#PROFILE_FILES[@]}" | |
| log "🔑 Shared API key: ${API_KEY}" | |
| log "🐳 Container prefix: ${CONTAINER_PREFIX}" | |
| if [[ "$DISABLE_AUTH_ROTATION" == "true" ]]; then | |
| log "🔒 Auth rotation: disabled" | |
| else | |
| log "🔁 Auth rotation: enabled" | |
| fi | |
| if [[ "$DRY_RUN" == "true" ]]; then | |
| log "🧪 Dry run mode was enabled. No containers were started." | |
| else | |
| docker ps --filter "name=^${CONTAINER_PREFIX}-" --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | |
| fi | |
| } | |
| main() { | |
| parse_args "$@" | |
| if [[ -n "${IMAGE_NAME:-}" && "$IMAGE_NAME" != "$DEFAULT_IMAGE_NAME" && "$IMAGE_NAME_SOURCE" == "default" ]]; then | |
| IMAGE_NAME_SOURCE="env" | |
| fi | |
| require_integer "BASE_API_PORT" "$BASE_API_PORT" | |
| require_integer "BASE_STREAM_PORT" "$BASE_STREAM_PORT" | |
| require_integer "MEMORY_PER_INSTANCE_GB" "$MEMORY_PER_INSTANCE_GB" | |
| validate_paths | |
| check_docker | |
| discover_profiles | |
| log "🔍 Found profiles: ${#PROFILE_FILES[@]} in $PROFILE_DIR" | |
| print_memory_warning | |
| confirm_launch | |
| local idx profile_path profile_basename profile_name container_name api_port stream_port | |
| for idx in "${!PROFILE_FILES[@]}"; do | |
| profile_path="${PROFILE_FILES[$idx]}" | |
| profile_basename="$(basename "$profile_path")" | |
| profile_name="${profile_basename%.json}" | |
| container_name="$(sanitize_name "${CONTAINER_PREFIX}-${profile_name}")" | |
| api_port=$((BASE_API_PORT + idx)) | |
| stream_port=$((BASE_STREAM_PORT + idx)) | |
| prepare_container_ports "$container_name" "$api_port" "$stream_port" | |
| launch_container "$profile_path" "$profile_name" "$api_port" "$stream_port" "$container_name" | |
| done | |
| print_summary | |
| } | |
| main "$@" | |