#!/usr/bin/env bash set -euo pipefail # One entry point for the complete local CASAN lab. It keeps the infrastructure # lab and the authenticated Control Panel as separate Compose projects so their # lifecycle can be managed without port/network collisions. # # Usage: local-full.sh start|stop|status|verify|smoke|env SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/casan-paths.sh" ROOT="$CASAN_APP_ROOT" INFRA="$SCRIPT_DIR/infra-lab.sh" CP_COMPOSE="$ROOT/docker-compose.control-panel.local.yml" LOCAL_ENV="$ROOT/infra/local-prod/casan.local.env" TLS_DIR="$ROOT/tmp/control-panel-local/tls" AUTH_BRIDGE_DIR="$ROOT/tmp/control-panel-local/auth-bridge" AUTH_BRIDGE_TOKEN_FILE="$AUTH_BRIDGE_DIR/token" AUTH_BRIDGE_PID_FILE="$AUTH_BRIDGE_DIR/bridge.pid" AUTH_BRIDGE_LOG="$AUTH_BRIDGE_DIR/bridge.log" AUTH_BRIDGE_AUDIT="$AUTH_BRIDGE_DIR/model-audit.jsonl" AUTH_BRIDGE="$ROOT/packages/casan-control-panel/scripts/provider-auth-bridge.py" CMD="${1:-status}" resolve_python() { if [[ -n "${CASAN_PYTHON_BIN:-}" ]]; then if command -v "$CASAN_PYTHON_BIN" >/dev/null 2>&1 && "$CASAN_PYTHON_BIN" --version >/dev/null 2>&1; then printf '%s\n' "$CASAN_PYTHON_BIN" return 0 fi echo "CASAN_LOCAL_PYTHON_INVALID path=$CASAN_PYTHON_BIN" >&2 return 1 fi # Prefer macOS' universal system Python over stale framework installs that may # appear first in PATH but are terminated by Gatekeeper/Rosetta on Apple Silicon. local candidate for candidate in /usr/bin/python3 python3 python; do if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then printf '%s\n' "$candidate" return 0 fi done echo "CASAN_LOCAL_PYTHON_MISSING" >&2 return 1 } prepare_docker_cli() { local original_config="${DOCKER_CONFIG:-$HOME/.docker}" local config_file="$original_config/config.json" [[ -f "$config_file" ]] || return 0 local python_bin credential_store helper context_name docker_host fallback_config python_bin="$(resolve_python)" || return 1 credential_store="$("$python_bin" - "$config_file" <<'PY' import json import sys try: with open(sys.argv[1], encoding="utf-8") as handle: print(json.load(handle).get("credsStore", "")) except (OSError, ValueError): print("") PY )" [[ -n "$credential_store" ]] || return 0 helper="$(command -v "docker-credential-$credential_store" 2>/dev/null || true)" if [[ -n "$helper" ]] && "$python_bin" - "$helper" <<'PY' >/dev/null 2>&1 import subprocess import sys raise SystemExit(subprocess.run( [sys.argv[1], "list"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ).returncode) PY then return 0 fi # CASAN's local compose files use public images only. If Docker Desktop's # credential helper is broken, isolate this process from it without changing # ~/.docker/config.json or touching any stored login credentials. context_name="$(docker context show)" docker_host="$(docker context inspect --format '{{(index .Endpoints "docker").Host}}' "$context_name")" [[ -n "$docker_host" ]] || { echo "CASAN_LOCAL_DOCKER_CONTEXT_INVALID context=$context_name" >&2; return 1; } fallback_config="${TMPDIR:-/tmp}/casan-docker-public-$UID" mkdir -p "$fallback_config" if [[ -d "$original_config/cli-plugins" && ! -e "$fallback_config/cli-plugins" ]]; then ln -s "$original_config/cli-plugins" "$fallback_config/cli-plugins" fi umask 077 printf '%s\n' '{"auths":{"https://index.docker.io/v1/":{},"quay.io":{}}}' > "$fallback_config/config.json" export DOCKER_CONFIG="$fallback_config" export DOCKER_HOST="$docker_host" export DOCKER_BUILDKIT=0 export COMPOSE_DOCKER_CLI_BUILD=0 echo "CASAN_LOCAL_DOCKER_CREDENTIAL_FALLBACK helper=$credential_store context=$context_name builder=classic" >&2 } cp_compose() { if [[ -f "$AUTH_BRIDGE_TOKEN_FILE" ]]; then export CASAN_AUTH_BRIDGE_TOKEN CASAN_AUTH_BRIDGE_TOKEN="$(tr -d '\r\n' < "$AUTH_BRIDGE_TOKEN_FILE")" fi if [[ -f "$LOCAL_ENV" ]]; then docker compose --env-file "$LOCAL_ENV" -f "$CP_COMPOSE" "$@" else docker compose -f "$CP_COMPOSE" "$@" fi } start_auth_bridge() { local python_bin python_bin="$(resolve_python)" || return 1 mkdir -p "$AUTH_BRIDGE_DIR" if [[ ! -s "$AUTH_BRIDGE_TOKEN_FILE" ]]; then openssl rand -hex 32 > "$AUTH_BRIDGE_TOKEN_FILE" chmod 600 "$AUTH_BRIDGE_TOKEN_FILE" fi if curl -fsS -m 2 "http://127.0.0.1:20130/healthz" >/dev/null 2>&1; then return 0 fi if [[ -f "$AUTH_BRIDGE_PID_FILE" ]]; then local existing_pid existing_pid="$(cat "$AUTH_BRIDGE_PID_FILE")" # A PID can be stale or reused by an unrelated process. Health is the # authoritative signal; clean the stale state before starting the bridge. kill "$existing_pid" 2>/dev/null || true rm -f "$AUTH_BRIDGE_PID_FILE" fi [[ -f "$AUTH_BRIDGE" ]] || { echo "CASAN_AUTH_BRIDGE_MISSING" >&2; return 1; } nohup "$python_bin" "$AUTH_BRIDGE" --bind 0.0.0.0 --port 20130 --token-file "$AUTH_BRIDGE_TOKEN_FILE" --audit-log "$AUTH_BRIDGE_AUDIT" > "$AUTH_BRIDGE_LOG" 2>&1 & echo "$!" > "$AUTH_BRIDGE_PID_FILE" chmod 600 "$AUTH_BRIDGE_PID_FILE" "$AUTH_BRIDGE_LOG" "$AUTH_BRIDGE_AUDIT" 2>/dev/null || true if ! wait_url "http://127.0.0.1:20130/healthz"; then kill "$(cat "$AUTH_BRIDGE_PID_FILE")" 2>/dev/null || true rm -f "$AUTH_BRIDGE_PID_FILE" return 1 fi } stop_auth_bridge() { if [[ -f "$AUTH_BRIDGE_PID_FILE" ]]; then local pid pid="$(cat "$AUTH_BRIDGE_PID_FILE")" kill "$pid" 2>/dev/null || true rm -f "$AUTH_BRIDGE_PID_FILE" fi rm -f "$AUTH_BRIDGE_TOKEN_FILE" } need_docker() { command -v docker >/dev/null 2>&1 || { echo "CASAN_LOCAL_DOCKER_MISSING" >&2; exit 1; } prepare_docker_cli docker compose version >/dev/null 2>&1 || { echo "CASAN_LOCAL_COMPOSE_MISSING" >&2; exit 1; } } ensure_tls() { mkdir -p "$TLS_DIR" if [[ ! -f "$TLS_DIR/tls.crt" || ! -f "$TLS_DIR/tls.key" ]]; then openssl req -x509 -newkey rsa:2048 -nodes \ -keyout "$TLS_DIR/tls.key" -out "$TLS_DIR/tls.crt" \ -subj "/CN=localhost" -days 30 >/dev/null 2>&1 fi } wait_url() { local url="$1" for _ in $(seq 1 60); do curl -k -fsS -m 3 "$url" >/dev/null 2>&1 && return 0 sleep 1 done echo "CASAN_LOCAL_WAIT_TIMEOUT url=$url" >&2 return 1 } case "$CMD" in start) need_docker bash "$INFRA" start ensure_tls start_auth_bridge cp_compose up -d --build wait_url "http://127.0.0.1:18082/healthz" # The unauthenticated console intentionally redirects, so test the OIDC IdP # here; `smoke` performs the complete logged-in browser flow. echo "CASAN_LOCAL_FULL_STARTED" echo "dashboard=http://127.0.0.1:18080 (basic auth: casan / casan)" echo "minio_console=http://127.0.0.1:19091 (casanadmin / casanadmin123)" echo "control_panel=https://localhost:18443 (self-signed TLS; mock OIDC login)" ;; stop) need_docker cp_compose down --remove-orphans stop_auth_bridge bash "$INFRA" stop echo "CASAN_LOCAL_FULL_STOPPED" ;; status) need_docker echo "=== infrastructure ===" bash "$INFRA" status echo "=== control panel ===" cp_compose ps if curl -fsS -m 2 "http://127.0.0.1:20130/healthz" >/dev/null 2>&1; then echo "provider_auth_bridge=running" else echo "provider_auth_bridge=stopped" fi ;; verify) need_docker bash "$INFRA" verify cp_compose ps wait_url "http://127.0.0.1:18082/healthz" wait_url "http://127.0.0.1:20130/healthz" echo "CASAN_LOCAL_FULL_VERIFY_PASS" ;; smoke) need_docker bash "$ROOT/packages/casan-control-panel/scripts/local-prod-smoke.sh" ;; env) bash "$INFRA" env printf '%s\n' \ '# OmniRoute is opt-in. Replace with an ID returned by /v1/models.' \ 'export CASAN_OPENAI_COMPATIBLE_BASE_URL=http://127.0.0.1:20128/v1' \ 'export CASAN_OPENAI_COMPATIBLE_API_KEY="$OPENAI_API_KEY"' \ 'export CASAN_MODEL_PRIMARY=openai-compatible:' \ 'export CASAN_CHAT_MODEL_MODE=model' \ 'export CASAN_CHAT_MODEL_PROVIDER=omniroute' \ 'export CASAN_PREFLIGHT=1' ;; *) echo "Usage: $0 start|stop|status|verify|smoke|env" >&2 exit 64 ;; esac