Files
CASAN/packages/casan-harness/scripts/bash/local-full.sh
T
2026-07-20 23:47:09 +07:00

243 lines
8.1 KiB
Bash
Executable File

#!/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 <model-id> 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:<model-id>' \
'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