#!/usr/bin/env bash # AI Admin Panel Installer # Usage: # curl -fsSL https://get.aiadminpanel.com | bash # bash install.sh [--dry-run] [--verbose] [--unattended] # # Supports: Ubuntu 22.04, Ubuntu 24.04, Debian 12 # Architectures: x86_64 (amd64), aarch64/arm64 # # --dry-run: Log all actions without executing them (safe for testing) # --verbose: Enable debug output # --unattended: Non-interactive mode (requires PANEL_DOMAIN env var) # # Environment variables (skip interactive prompts when set): # PANEL_DOMAIN - Domain for the panel (e.g., panel.example.com) # ACME_EMAIL - Email for Let's Encrypt certificates # PANEL_VERSION - Version to install (default: latest) # CF_DNS_API_TOKEN - Cloudflare DNS API token for wildcard certs (optional) # GOMEMLIMIT - Go memory limit (optional) set -euo pipefail # ── Constants ───────────────────────────────────────────────────────────────── PANEL_DIR="/opt/aiadminpanel" CONFIG_DIR="/etc/aiadminpanel" LOG_DIR="/var/log/aiadminpanel" SECRETS_DIR="/run/secrets" INSTALL_LOG="${LOG_DIR}/install.log" GITHUB_RELEASE_BASE="https://github.com/iskraecommerce/ai-admin-panel/releases" PANEL_VERSION="${PANEL_VERSION:-latest}" PANEL_DOMAIN="${PANEL_DOMAIN:-}" ACME_EMAIL="${ACME_EMAIL:-admin@example.com}" TOTAL_STEPS=16 CURRENT_STEP=0 INSTALL_START=$(date +%s) # ── Flags ───────────────────────────────────────────────────────────────────── DRY_RUN=false VERBOSE=false UNATTENDED=false for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=true ;; --verbose) VERBOSE=true ;; --unattended) UNATTENDED=true ;; *) echo "Unknown argument: $arg" >&2; exit 1 ;; esac done # ── Color Support ──────────────────────────────────────────────────────────── COLOR_GREEN="" COLOR_YELLOW="" COLOR_RED="" COLOR_CYAN="" COLOR_BOLD="" COLOR_RESET="" setup_colors() { if [ -t 1 ] && command -v tput &>/dev/null && [ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]; then COLOR_GREEN=$(tput setaf 2) COLOR_YELLOW=$(tput setaf 3) COLOR_RED=$(tput setaf 1) COLOR_CYAN=$(tput setaf 6) COLOR_BOLD=$(tput bold) COLOR_RESET=$(tput sgr0) fi } setup_colors # ── Logging ─────────────────────────────────────────────────────────────────── log() { local level="$1" shift local msg="$*" local ts ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') local line="[$ts] [$level] $msg" # Colorized terminal output case "$level" in "INFO ") echo "${COLOR_GREEN}${line}${COLOR_RESET}" ;; "WARN ") echo "${COLOR_YELLOW}${line}${COLOR_RESET}" ;; "ERROR") echo "${COLOR_RED}${line}${COLOR_RESET}" ;; "DRY ") echo "${COLOR_CYAN}${line}${COLOR_RESET}" ;; *) echo "$line" ;; esac if [ -d "$(dirname "$INSTALL_LOG")" ]; then echo "$line" >> "$INSTALL_LOG" 2>/dev/null || true fi } info() { log "INFO " "$@"; } warn() { log "WARN " "$@"; } error() { log "ERROR" "$@"; exit 1; } debug() { [ "$VERBOSE" = "true" ] && log "DEBUG" "$@" || true; } dryrun() { log "DRY " "[WOULD] $*"; } step() { CURRENT_STEP=$((CURRENT_STEP + 1)) echo "" echo "${COLOR_BOLD}[${CURRENT_STEP}/${TOTAL_STEPS}] $*${COLOR_RESET}" } run() { if [ "$DRY_RUN" = "true" ]; then dryrun "$@" else debug "Running: $*" "$@" fi } # ── Root Check ──────────────────────────────────────────────────────────────── check_root() { if [ "$EUID" -ne 0 ]; then if [ "$DRY_RUN" = "true" ]; then warn "Not running as root -- dry-run mode allows non-root execution" return 0 fi error "This installer must be run as root. Try: sudo bash $0 $*" fi info "Running as root" } # ── Domain Prompt ──────────────────────────────────────────────────────────── prompt_domain() { if [ -n "$PANEL_DOMAIN" ]; then info "Using domain from environment: ${PANEL_DOMAIN}" return 0 fi if [ "$UNATTENDED" = "true" ]; then error "PANEL_DOMAIN must be set when using --unattended mode" fi if [ "$DRY_RUN" = "true" ]; then PANEL_DOMAIN="dry-run.example.com" dryrun "Would prompt for domain, using placeholder: ${PANEL_DOMAIN}" return 0 fi echo "" read -rp "${COLOR_BOLD}Enter panel domain (e.g., panel.example.com): ${COLOR_RESET}" PANEL_DOMAIN if [ -z "$PANEL_DOMAIN" ]; then error "Domain is required. Set PANEL_DOMAIN env var or provide interactively." fi info "Domain set to: ${PANEL_DOMAIN}" } # ── OS Detection ────────────────────────────────────────────────────────────── detect_os() { if [ ! -f /etc/os-release ]; then error "Cannot detect OS: /etc/os-release not found" fi . /etc/os-release OS_ID="$ID" OS_VERSION_ID="$VERSION_ID" info "Detected OS: $PRETTY_NAME" case "$OS_ID" in ubuntu) case "$OS_VERSION_ID" in 22.04|24.04) ;; *) error "Unsupported Ubuntu version: $OS_VERSION_ID (supported: 22.04, 24.04)" ;; esac ;; debian) case "$OS_VERSION_ID" in 12) ;; *) error "Unsupported Debian version: $OS_VERSION_ID (supported: 12)" ;; esac ;; *) error "Unsupported OS: $OS_ID (supported: Ubuntu 22.04, Ubuntu 24.04, Debian 12)" ;; esac } # ── Architecture Detection ──────────────────────────────────────────────────── detect_arch() { ARCH=$(uname -m) case "$ARCH" in x86_64) BINARY_SUFFIX="amd64" ;; aarch64|arm64) BINARY_SUFFIX="arm64" ;; *) error "Unsupported architecture: $ARCH (supported: x86_64, aarch64/arm64)" ;; esac info "Architecture: $ARCH ($BINARY_SUFFIX)" } # ── Port Conflict Check ─────────────────────────────────────────────────────── check_ports() { info "Checking for port conflicts..." local conflict=false for port in 80 443; do if ss -tlnp "sport = :$port" 2>/dev/null | grep -q LISTEN; then if [ "$DRY_RUN" = "true" ]; then warn "[DRY-RUN] Port $port is in use -- would abort in production install" conflict=true else error "Port $port is already in use. Please free the port and re-run the installer." fi else debug "Port $port is available" fi done if [ "$conflict" = "false" ]; then info "Ports 80 and 443 are available" fi } # ── Docker Installation ─────────────────────────────────────────────────────── install_docker() { if command -v docker &>/dev/null; then DOCKER_VERSION=$(docker --version | grep -oP '\d+\.\d+\.\d+' | head -1) DOCKER_MAJOR=$(echo "$DOCKER_VERSION" | cut -d. -f1) if [ "$DOCKER_MAJOR" -ge 24 ]; then info "Docker $DOCKER_VERSION already installed (OK)" return 0 fi warn "Docker $DOCKER_VERSION is older than required (24+). Upgrading..." fi info "Installing Docker Engine..." if [ "$DRY_RUN" = "true" ]; then dryrun "curl -fsSL https://get.docker.com | sh" dryrun "systemctl enable --now docker" return 0 fi # Official Docker install script curl -fsSL https://get.docker.com | sh systemctl enable --now docker docker --version | grep -oP '\d+' | head -1 | { read major if [ "$major" -lt 24 ]; then error "Docker installation failed: version is still below 24" fi } info "Docker installed successfully" } check_docker_compose() { if [ "$DRY_RUN" = "true" ]; then dryrun "Check docker compose plugin availability" return 0 fi if ! docker compose version &>/dev/null; then info "Installing Docker Compose plugin..." run apt-get install -y docker-compose-plugin else info "Docker Compose plugin already installed" fi } # ── Directory Setup ─────────────────────────────────────────────────────────── create_directories() { info "Creating panel directories..." for dir in "$PANEL_DIR" "$CONFIG_DIR" "$LOG_DIR" "$SECRETS_DIR"; do if [ -d "$dir" ]; then debug "Directory exists: $dir" else run mkdir -p "$dir" info "Created: $dir" fi done } # ── Secret Generation ───────────────────────────────────────────────────────── generate_secrets() { info "Generating secrets..." # Master key (256-bit / 32 bytes, hex encoded = 64 chars) if [ -f "${SECRETS_DIR}/master_key" ] && [ "$DRY_RUN" = "false" ]; then info "Master key already exists, keeping existing" else run bash -c "dd if=/dev/urandom bs=32 count=1 2>/dev/null | xxd -p -c 64 > ${SECRETS_DIR}/master_key" run chmod 600 "${SECRETS_DIR}/master_key" info "Generated master key" fi # PostgreSQL password (32 bytes random, base64 URL-safe) if [ -f "${SECRETS_DIR}/db_password" ] && [ "$DRY_RUN" = "false" ]; then info "DB password already exists, keeping existing" else run bash -c "dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64 -w0 | tr '+/' '-_' > ${SECRETS_DIR}/db_password" run chmod 600 "${SECRETS_DIR}/db_password" info "Generated DB password" fi } # ── Keycloak Setup ──────────────────────────────────────────────────────────── setup_keycloak() { info "Setting up Keycloak identity server..." # Generate Keycloak admin password if [ -f "${SECRETS_DIR}/keycloak_admin_password" ] && [ "$DRY_RUN" = "false" ]; then info "Keycloak admin password already exists, keeping existing" else run bash -c "dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64 -w0 | tr '+/' '-_' > ${SECRETS_DIR}/keycloak_admin_password" run chmod 600 "${SECRETS_DIR}/keycloak_admin_password" info "Generated Keycloak admin password" fi # Create Keycloak database in PostgreSQL if [ "$DRY_RUN" = "true" ]; then dryrun "CREATE DATABASE keycloak" return 0 fi # Run DB setup via docker exec (postgres container is already running) docker exec aiadminpanel_postgresql psql -U aiadminpanel -c \ "SELECT 1 FROM pg_database WHERE datname='keycloak'" 2>/dev/null | grep -q "1 row" || \ docker exec aiadminpanel_postgresql psql -U aiadminpanel -c \ "CREATE DATABASE keycloak;" 2>/dev/null || true info "Keycloak database configured" # Copy realm export for auto-import on first boot local realm_export="${PANEL_DIR}/keycloak-realm-export.json" if [ ! -f "$realm_export" ]; then # Try to find the realm export bundled with the install local bundled_paths=( "$(dirname "$(readlink -f "$0")")/../keycloak-realm-export.json" "/tmp/aiadminpanel-keycloak-realm-export.json" ) for path in "${bundled_paths[@]}"; do if [ -f "$path" ]; then cp "$path" "$realm_export" chmod 644 "$realm_export" info "Realm export copied from ${path}" return 0 fi done warn "Keycloak realm export not found — Keycloak will start with default realm. Import manually." else info "Keycloak realm export already exists at ${realm_export}" fi } create_river_tables() { info "Creating River queue tables (if not exist)..." if [ "$DRY_RUN" = "true" ]; then dryrun "CREATE river_migration, river_job, river_leader, river_queue, river_client tables" return 0 fi # Check if river tables already exist (idempotent) if docker exec aiadminpanel_postgresql psql -U aiadminpanel -d aiadminpanel -tAc \ "SELECT 1 FROM information_schema.tables WHERE table_name='river_job'" 2>/dev/null | grep -q "1"; then info "River tables already exist, skipping" return 0 fi # River v0.31.0 migration SQL (from riverdriver/riverpgxv5 migration/main/001-006) # All 6 migrations combined, stripped of /* TEMPLATE: schema */ markers docker exec aiadminpanel_postgresql psql -U aiadminpanel -d aiadminpanel <<'RIVERSQL' -- Migration 001: Create river_migration table CREATE TABLE IF NOT EXISTS river_migration( id bigserial PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT NOW(), version bigint NOT NULL, CONSTRAINT version CHECK (version >= 1) ); CREATE UNIQUE INDEX IF NOT EXISTS river_migration_version_idx ON river_migration USING btree(version); -- Migration 002: Initial schema (river_job, river_leader) DO $$ BEGIN CREATE TYPE river_job_state AS ENUM( 'available', 'cancelled', 'completed', 'discarded', 'retryable', 'running', 'scheduled' ); EXCEPTION WHEN duplicate_object THEN NULL; END $$; CREATE TABLE IF NOT EXISTS river_job( id bigserial PRIMARY KEY, state river_job_state NOT NULL DEFAULT 'available', attempt smallint NOT NULL DEFAULT 0, max_attempts smallint NOT NULL, attempted_at timestamptz, created_at timestamptz NOT NULL DEFAULT NOW(), finalized_at timestamptz, scheduled_at timestamptz NOT NULL DEFAULT NOW(), priority smallint NOT NULL DEFAULT 1, args jsonb, attempted_by text[], errors jsonb[], kind text NOT NULL, metadata jsonb NOT NULL DEFAULT '{}', queue text NOT NULL DEFAULT 'default', tags varchar(255)[] NOT NULL DEFAULT '{}', CONSTRAINT finalized_or_finalized_at_null CHECK ( (finalized_at IS NULL AND state NOT IN ('cancelled', 'completed', 'discarded')) OR (finalized_at IS NOT NULL AND state IN ('cancelled', 'completed', 'discarded')) ), CONSTRAINT max_attempts_is_positive CHECK (max_attempts > 0), CONSTRAINT priority_in_range CHECK (priority >= 1 AND priority <= 4), CONSTRAINT queue_length CHECK (char_length(queue) > 0 AND char_length(queue) < 128), CONSTRAINT kind_length CHECK (char_length(kind) > 0 AND char_length(kind) < 128) ); CREATE INDEX IF NOT EXISTS river_job_kind ON river_job USING btree(kind); CREATE INDEX IF NOT EXISTS river_job_state_and_finalized_at_index ON river_job USING btree(state, finalized_at) WHERE finalized_at IS NOT NULL; CREATE INDEX IF NOT EXISTS river_job_prioritized_fetching_index ON river_job USING btree(state, queue, priority, scheduled_at, id); CREATE INDEX IF NOT EXISTS river_job_args_index ON river_job USING GIN(args); CREATE INDEX IF NOT EXISTS river_job_metadata_index ON river_job USING GIN(metadata); CREATE UNLOGGED TABLE IF NOT EXISTS river_leader( elected_at timestamptz NOT NULL, expires_at timestamptz NOT NULL, leader_id text NOT NULL, name text PRIMARY KEY DEFAULT 'default', CONSTRAINT name_length CHECK (name = 'default'), CONSTRAINT leader_id_length CHECK (char_length(leader_id) > 0 AND char_length(leader_id) < 128) ); -- Migration 004: Add pending state, create river_queue ALTER TYPE river_job_state ADD VALUE IF NOT EXISTS 'pending' AFTER 'discarded'; CREATE TABLE IF NOT EXISTS river_queue( name text PRIMARY KEY NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), metadata jsonb NOT NULL DEFAULT '{}', paused_at timestamptz, updated_at timestamptz NOT NULL DEFAULT now() ); -- Migration 005: Rebuild river_migration with line support, add unique_key, create river_client DO $body$ BEGIN IF (SELECT to_regclass('river_migration') IS NOT NULL) THEN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='river_migration' AND column_name='line') THEN ALTER TABLE river_migration RENAME TO river_migration_old; CREATE TABLE river_migration( line TEXT NOT NULL, version bigint NOT NULL, created_at timestamptz NOT NULL DEFAULT NOW(), CONSTRAINT line_length CHECK (char_length(line) > 0 AND char_length(line) < 128), CONSTRAINT version_gte_1 CHECK (version >= 1), PRIMARY KEY (line, version) ); INSERT INTO river_migration (created_at, line, version) SELECT created_at, 'main', version FROM river_migration_old; DROP TABLE river_migration_old; END IF; END IF; END; $body$ LANGUAGE plpgsql; ALTER TABLE river_job ADD COLUMN IF NOT EXISTS unique_key bytea; CREATE UNIQUE INDEX IF NOT EXISTS river_job_kind_unique_key_idx ON river_job (kind, unique_key) WHERE unique_key IS NOT NULL; CREATE UNLOGGED TABLE IF NOT EXISTS river_client( id text PRIMARY KEY NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), metadata jsonb NOT NULL DEFAULT '{}', paused_at timestamptz, updated_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT name_length CHECK (char_length(id) > 0 AND char_length(id) < 128) ); CREATE UNLOGGED TABLE IF NOT EXISTS river_client_queue( river_client_id text NOT NULL REFERENCES river_client(id) ON DELETE CASCADE, name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), max_workers bigint NOT NULL DEFAULT 0, metadata jsonb NOT NULL DEFAULT '{}', num_jobs_completed bigint NOT NULL DEFAULT 0, num_jobs_running bigint NOT NULL DEFAULT 0, updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (river_client_id, name), CONSTRAINT name_length CHECK (char_length(name) > 0 AND char_length(name) < 128), CONSTRAINT num_jobs_completed_zero_or_positive CHECK (num_jobs_completed >= 0), CONSTRAINT num_jobs_running_zero_or_positive CHECK (num_jobs_running >= 0) ); -- Migration 006: Add river_job_state_in_bitmask function and unique_states column CREATE OR REPLACE FUNCTION river_job_state_in_bitmask(bitmask BIT(8), state river_job_state) RETURNS boolean LANGUAGE SQL IMMUTABLE AS $$ SELECT CASE state WHEN 'available' THEN get_bit(bitmask, 7) WHEN 'cancelled' THEN get_bit(bitmask, 6) WHEN 'completed' THEN get_bit(bitmask, 5) WHEN 'discarded' THEN get_bit(bitmask, 4) WHEN 'pending' THEN get_bit(bitmask, 3) WHEN 'retryable' THEN get_bit(bitmask, 2) WHEN 'running' THEN get_bit(bitmask, 1) WHEN 'scheduled' THEN get_bit(bitmask, 0) ELSE 0 END = 1; $$; ALTER TABLE river_job ADD COLUMN IF NOT EXISTS unique_states BIT(8); CREATE UNIQUE INDEX IF NOT EXISTS river_job_unique_idx ON river_job (unique_key) WHERE unique_key IS NOT NULL AND unique_states IS NOT NULL AND river_job_state_in_bitmask(unique_states, state); -- Record migration versions so River recognizes the schema INSERT INTO river_migration (line, version) VALUES ('main', 1) ON CONFLICT DO NOTHING; INSERT INTO river_migration (line, version) VALUES ('main', 2) ON CONFLICT DO NOTHING; INSERT INTO river_migration (line, version) VALUES ('main', 3) ON CONFLICT DO NOTHING; INSERT INTO river_migration (line, version) VALUES ('main', 4) ON CONFLICT DO NOTHING; INSERT INTO river_migration (line, version) VALUES ('main', 5) ON CONFLICT DO NOTHING; INSERT INTO river_migration (line, version) VALUES ('main', 6) ON CONFLICT DO NOTHING; RIVERSQL if [ $? -eq 0 ]; then info "River queue tables created successfully" else warn "River table creation had errors (may be partially created -- panel will retry)" fi } wait_for_keycloak() { local max_attempts=120 local attempt=0 info "Waiting for Keycloak to become healthy (up to 120s)..." if [ "$DRY_RUN" = "true" ]; then dryrun "poll docker inspect aiadminpanel_keycloak health status up to 120s" return 0 fi while [ "$attempt" -lt "$max_attempts" ]; do local health health=$(docker inspect --format='{{.State.Health.Status}}' aiadminpanel_keycloak 2>/dev/null || echo "unknown") if [ "$health" = "healthy" ]; then info "Keycloak is healthy" return 0 fi attempt=$((attempt + 1)) sleep 1 done warn "Keycloak did not become healthy within 120s. Check logs:" warn " docker logs aiadminpanel_keycloak" return 1 } # ── Config Generation ───────────────────────────────────────────────────────── generate_config() { local config_file="${CONFIG_DIR}/config.yaml" if [ -f "$config_file" ] && [ "$DRY_RUN" = "false" ]; then info "Config file already exists at ${config_file}, keeping existing" return 0 fi info "Generating config file..." run bash -c "cat > '${config_file}'" << 'EOF' # AI Admin Panel Configuration # Generated by installer. Edit as needed. server: port: 8080 log_level: info database: max_conns: 25 min_conns: 5 metrics: prometheus_enabled: false collection_interval: 15s notifications: categories: service_events: true ai_ops: true system_alerts: true security_events: true updater: check_enabled: true check_interval: 24h EOF run chmod 644 "$config_file" info "Config written to ${config_file}" } # ── Environment File Generation ────────────────────────────────────────────── generate_env_file() { local env_file="${PANEL_DIR}/.env" info "Generating environment file for docker-compose..." # Idempotency: don't overwrite existing .env (secrets/tokens may differ) if [ -f "$env_file" ] && [ "$DRY_RUN" = "false" ]; then info "Environment file already exists at ${env_file}, keeping existing" return 0 fi if [ "$DRY_RUN" = "true" ]; then dryrun "Write ${env_file} with PANEL_DOMAIN, ACME_EMAIL, PANEL_VERSION" dryrun "Write Keycloak OIDC vars to ${env_file}" dryrun "Write PANEL_INTERNAL_URL=http://panel:8080 to ${env_file}" return 0 fi cat > "$env_file" <> "$env_file" fi # Keycloak OIDC configuration (internal Docker network) echo "OIDC_DISCOVERY_URL=http://keycloak:8180/realms/aiadminpanel/.well-known/openid-configuration" >> "$env_file" echo "OIDC_CLIENT_ID=panel-backend" >> "$env_file" echo "KEYCLOAK_URL=http://keycloak:8180" >> "$env_file" echo "KEYCLOAK_REALM=aiadminpanel" >> "$env_file" echo "KEYCLOAK_ADMIN_USER=admin" >> "$env_file" echo "OIDC_REDIRECT_URI=https://${PANEL_DOMAIN}/auth/callback" >> "$env_file" echo "KEYCLOAK_HOSTNAME=auth.${PANEL_DOMAIN}" >> "$env_file" echo "PANEL_INTERNAL_URL=http://panel:8080" >> "$env_file" chmod 600 "$env_file" info "Environment file written to ${env_file}" } # ── Docker Compose Deployment ───────────────────────────────────────────────── deploy_stack() { info "Deploying panel stack..." local compose_file="${PANEL_DIR}/docker-compose.yml" # Check if existing installation if [ -f "$compose_file" ] && docker compose -f "$compose_file" ps 2>/dev/null | grep -q "running"; then info "Existing installation detected -- performing upgrade" run docker compose -f "$compose_file" pull run docker compose -f "$compose_file" up -d --remove-orphans return 0 fi # Fresh install -- download docker-compose.yml from GitHub Release local release_url if [ "$PANEL_VERSION" = "latest" ]; then release_url="${GITHUB_RELEASE_BASE}/latest/download/docker-compose.yml" else release_url="${GITHUB_RELEASE_BASE}/download/v${PANEL_VERSION}/docker-compose.yml" fi info "Downloading docker-compose.yml from ${release_url}..." local download_ok=false if [ "$DRY_RUN" = "true" ]; then dryrun "curl -fsSL ${release_url} -o ${compose_file}" download_ok=true elif curl -fsSL "$release_url" -o "$compose_file" 2>/dev/null; then download_ok=true fi if [ "$download_ok" = "false" ]; then # Fallback: no GitHub Release published yet (pre-release install). # Use the docker-compose.yml bundled alongside the install script or panel binary. warn "Could not download docker-compose.yml from GitHub Release -- using local fallback" local fallback_paths=( "$(dirname "$(readlink -f "$0")")/../docker-compose.yml" # Relative to install script in repo "${PANEL_DIR}/docker-compose.yml.bundled" # Pre-placed by manual install "/tmp/aiadminpanel-docker-compose.yml" # Placed by CI/build pipeline ) local found_fallback=false for fb_path in "${fallback_paths[@]}"; do if [ -f "$fb_path" ]; then info "Using fallback docker-compose.yml from ${fb_path}" cp "$fb_path" "$compose_file" found_fallback=true break fi done if [ "$found_fallback" = "false" ]; then error "No docker-compose.yml available: GitHub download failed and no local fallback found. Place docker-compose.yml at ${PANEL_DIR}/docker-compose.yml and re-run." fi else # Download succeeded -- verify checksum local checksums_url if [ "$PANEL_VERSION" = "latest" ]; then checksums_url="${GITHUB_RELEASE_BASE}/latest/download/checksums.txt" else checksums_url="${GITHUB_RELEASE_BASE}/download/v${PANEL_VERSION}/checksums.txt" fi info "Verifying docker-compose.yml checksum..." if [ "$DRY_RUN" = "true" ]; then dryrun "Download checksums.txt and verify sha256sum" else local checksums_file checksums_file=$(mktemp) if curl -fsSL "$checksums_url" -o "$checksums_file" 2>/dev/null; then local expected_hash actual_hash expected_hash=$(grep "docker-compose.yml" "$checksums_file" | awk '{print $1}') actual_hash=$(sha256sum "$compose_file" | awk '{print $1}') if [ -n "$expected_hash" ] && [ "$expected_hash" != "$actual_hash" ]; then rm -f "$checksums_file" error "Checksum verification failed for docker-compose.yml (expected: ${expected_hash}, got: ${actual_hash})" fi info "Checksum verification passed" rm -f "$checksums_file" else warn "Could not download checksums.txt -- skipping checksum verification" rm -f "$checksums_file" fi fi fi run docker compose -f "$compose_file" up -d info "Stack deployed" } # ── Readiness Wait ──────────────────────────────────────────────────────────── wait_for_ready() { local url="http://localhost:8080/readyz" local max_attempts=60 local attempt=0 info "Waiting for panel to become ready (up to 60s)..." if [ "$DRY_RUN" = "true" ]; then dryrun "poll ${url} up to ${max_attempts}s" return 0 fi while [ "$attempt" -lt "$max_attempts" ]; do if curl -sf "$url" | grep -q '"status":"ready"' 2>/dev/null; then info "Panel is ready" return 0 fi attempt=$((attempt + 1)) sleep 1 done warn "Panel did not become ready within 60s. Check logs with: docker compose -f ${PANEL_DIR}/docker-compose.yml logs panel" return 1 } # ── Smoke Tests ─────────────────────────────────────────────────────────────── smoke_tests() { info "Running post-install smoke tests..." local all_pass=true if [ "$DRY_RUN" = "true" ]; then dryrun "curl -f http://localhost/healthz" dryrun "curl -f http://localhost/readyz" dryrun "curl -f http://localhost/" return 0 fi # Test healthz if curl -sf "http://localhost/healthz" | grep -q '"status":"ok"' 2>/dev/null; then info "[PASS] /healthz returns 200 with status ok" else warn "[FAIL] /healthz did not return expected response" all_pass=false fi # Test readyz if curl -sf "http://localhost/readyz" | grep -q '"status"' 2>/dev/null; then info "[PASS] /readyz is responding" else warn "[FAIL] /readyz did not respond" all_pass=false fi # Test frontend if curl -sf "http://localhost/" | grep -q "html" 2>/dev/null; then info "[PASS] Frontend is serving HTML" else warn "[FAIL] Frontend did not return HTML" all_pass=false fi if [ "$all_pass" = "false" ]; then warn "Some smoke tests failed. Check container logs:" warn " docker compose -f ${PANEL_DIR}/docker-compose.yml logs" return 1 fi info "All smoke tests passed" } # ── Security Baselines ──────────────────────────────────────────────────────── run_security_baselines() { local baselines_script baselines_script="$(dirname "$0")/security-baselines.sh" if [ -f "$baselines_script" ]; then info "Applying security baselines..." local dry_arg="" [ "$DRY_RUN" = "true" ] && dry_arg="--dry-run" run bash "$baselines_script" $dry_arg else warn "Security baselines script not found at ${baselines_script} -- skipping" fi } # ── Print Success ───────────────────────────────────────────────────────────── print_success() { local domain="${PANEL_DOMAIN:-localhost}" local elapsed_secs=$(( $(date +%s) - INSTALL_START )) local elapsed_min=$(( elapsed_secs / 60 )) local elapsed_sec=$(( elapsed_secs % 60 )) local ELAPSED_DISPLAY="${elapsed_min}m ${elapsed_sec}s" cat < Notifications Elapsed time: ${ELAPSED_DISPLAY} ${COLOR_GREEN}${COLOR_BOLD}===========================================================${COLOR_RESET} EOF } # ── Main ────────────────────────────────────────────────────────────────────── main() { if [ "$DRY_RUN" = "true" ]; then info "=== DRY RUN MODE: No changes will be made ===" fi # Ensure log directory exists early (best-effort) mkdir -p "$LOG_DIR" 2>/dev/null || true echo "" echo "${COLOR_BOLD}AI Admin Panel Installer${COLOR_RESET}" echo "Version: ${PANEL_VERSION}" echo "" info "AI Admin Panel Installer v${PANEL_VERSION}" info "Install log: ${INSTALL_LOG}" step "Checking prerequisites..." check_root "$@" step "Prompting for configuration..." prompt_domain step "Detecting operating system..." detect_os step "Detecting architecture..." detect_arch step "Checking port availability..." check_ports step "Creating directories..." create_directories step "Installing Docker..." install_docker step "Checking Docker Compose..." check_docker_compose step "Generating secrets..." generate_secrets step "Generating configuration..." generate_config step "Generating environment file..." generate_env_file step "Deploying panel stack..." deploy_stack step "Setting up Keycloak..." setup_keycloak step "Waiting for Keycloak readiness..." wait_for_keycloak step "Creating River queue tables..." create_river_tables step "Waiting for panel readiness..." wait_for_ready run_security_baselines smoke_tests if [ "$DRY_RUN" = "false" ]; then print_success else info "=== DRY RUN complete: no changes were made ===" fi } main "$@"