The DockerTenantProvisioner hardcoded SPRING_DATASOURCE_USERNAME and SPRING_DATASOURCE_PASSWORD as "cameleer" / "cameleer_dev". With the installer generating random passwords, tenant servers failed to connect to PostgreSQL. Add datasourceUsername and datasourcePassword to ProvisioningProperties, pass them from the compose env vars, and use them in the provisioner. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1433 lines
43 KiB
Bash
1433 lines
43 KiB
Bash
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
CAMELEER_INSTALLER_VERSION="1.0.0"
|
||
CAMELEER_DEFAULT_VERSION="latest"
|
||
REGISTRY="gitea.siegeln.net/cameleer"
|
||
|
||
# --- Colors ---
|
||
RED='\033[0;31m'
|
||
GREEN='\033[0;32m'
|
||
YELLOW='\033[1;33m'
|
||
BLUE='\033[0;34m'
|
||
BOLD='\033[1m'
|
||
NC='\033[0m'
|
||
|
||
# --- Defaults ---
|
||
DEFAULT_INSTALL_DIR="./cameleer"
|
||
DEFAULT_PUBLIC_PROTOCOL="https"
|
||
DEFAULT_ADMIN_USER="admin"
|
||
DEFAULT_TLS_MODE="self-signed"
|
||
DEFAULT_HTTP_PORT="80"
|
||
DEFAULT_HTTPS_PORT="443"
|
||
DEFAULT_LOGTO_CONSOLE_PORT="3002"
|
||
DEFAULT_LOGTO_CONSOLE_EXPOSED="true"
|
||
DEFAULT_VENDOR_ENABLED="false"
|
||
DEFAULT_VENDOR_USER="vendor"
|
||
DEFAULT_COMPOSE_PROJECT="cameleer-saas"
|
||
DEFAULT_DOCKER_SOCKET="/var/run/docker.sock"
|
||
|
||
# --- Config values (set by args/env/config/prompts) ---
|
||
# Save environment values before initialization (CLI args override these)
|
||
_ENV_PUBLIC_HOST="${PUBLIC_HOST:-}"
|
||
_ENV_PUBLIC_PROTOCOL="${PUBLIC_PROTOCOL:-}"
|
||
_ENV_POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
|
||
_ENV_CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-}"
|
||
_ENV_TLS_MODE="${TLS_MODE:-}"
|
||
_ENV_CERT_FILE="${CERT_FILE:-}"
|
||
_ENV_KEY_FILE="${KEY_FILE:-}"
|
||
_ENV_CA_FILE="${CA_FILE:-}"
|
||
_ENV_HTTP_PORT="${HTTP_PORT:-}"
|
||
_ENV_HTTPS_PORT="${HTTPS_PORT:-}"
|
||
_ENV_LOGTO_CONSOLE_PORT="${LOGTO_CONSOLE_PORT:-}"
|
||
_ENV_LOGTO_CONSOLE_EXPOSED="${LOGTO_CONSOLE_EXPOSED:-}"
|
||
_ENV_VENDOR_ENABLED="${VENDOR_ENABLED:-}"
|
||
_ENV_VENDOR_USER="${VENDOR_USER:-}"
|
||
_ENV_VENDOR_PASS="${VENDOR_PASS:-}"
|
||
_ENV_MONITORING_NETWORK="${MONITORING_NETWORK:-}"
|
||
_ENV_COMPOSE_PROJECT="${COMPOSE_PROJECT:-}"
|
||
_ENV_DOCKER_SOCKET="${DOCKER_SOCKET:-}"
|
||
_ENV_NODE_TLS_REJECT="${NODE_TLS_REJECT:-}"
|
||
|
||
INSTALL_DIR=""
|
||
PUBLIC_HOST=""
|
||
PUBLIC_PROTOCOL=""
|
||
ADMIN_USER=""
|
||
ADMIN_PASS=""
|
||
TLS_MODE=""
|
||
CERT_FILE=""
|
||
KEY_FILE=""
|
||
CA_FILE=""
|
||
POSTGRES_PASSWORD=""
|
||
CLICKHOUSE_PASSWORD=""
|
||
HTTP_PORT=""
|
||
HTTPS_PORT=""
|
||
LOGTO_CONSOLE_PORT=""
|
||
LOGTO_CONSOLE_EXPOSED=""
|
||
VENDOR_ENABLED=""
|
||
VENDOR_USER=""
|
||
VENDOR_PASS=""
|
||
MONITORING_NETWORK=""
|
||
VERSION=""
|
||
COMPOSE_PROJECT=""
|
||
DOCKER_SOCKET=""
|
||
NODE_TLS_REJECT=""
|
||
TENANT_ORG_NAME=""
|
||
|
||
# --- State ---
|
||
MODE="" # simple, expert, silent
|
||
IS_RERUN=false
|
||
RERUN_ACTION="" # upgrade, reconfigure, reinstall
|
||
CONFIRM_DESTROY=false
|
||
CONFIG_FILE_PATH=""
|
||
|
||
# --- Utility functions ---
|
||
|
||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||
log_success() { echo -e "${GREEN}[OK]${NC} $1"; }
|
||
|
||
print_banner() {
|
||
echo -e "${BOLD}"
|
||
echo " ____ _ "
|
||
echo " / ___|__ _ _ __ ___ ___ | | ___ ___ _ __ "
|
||
echo "| | / _\` | '_ \` _ \\ / _ \\| |/ _ \\/ _ \\ '__|"
|
||
echo "| |__| (_| | | | | | | __/| | __/ __/ | "
|
||
echo " \\____\\__,_|_| |_| |_|\\___||_|\\___|\\___||_| "
|
||
echo ""
|
||
echo " SaaS Platform Installer v${CAMELEER_INSTALLER_VERSION}"
|
||
echo -e "${NC}"
|
||
}
|
||
|
||
prompt() {
|
||
local var_name="$1" prompt_text="$2" default="${3:-}"
|
||
local input
|
||
if [ -n "$default" ]; then
|
||
read -rp " $prompt_text [$default]: " input
|
||
eval "$var_name=\"\${input:-$default}\""
|
||
else
|
||
read -rp " $prompt_text: " input
|
||
eval "$var_name=\"\$input\""
|
||
fi
|
||
}
|
||
|
||
prompt_password() {
|
||
local var_name="$1" prompt_text="$2" default="${3:-}"
|
||
local input
|
||
if [ -n "$default" ]; then
|
||
read -rsp " $prompt_text [${default:+********}]: " input
|
||
echo
|
||
eval "$var_name=\"\${input:-$default}\""
|
||
else
|
||
read -rsp " $prompt_text: " input
|
||
echo
|
||
eval "$var_name=\"\$input\""
|
||
fi
|
||
}
|
||
|
||
prompt_yesno() {
|
||
local prompt_text="$1" default="${2:-n}"
|
||
local input
|
||
if [ "$default" = "y" ]; then
|
||
read -rp " $prompt_text [Y/n]: " input
|
||
case "${input:-y}" in
|
||
[nN]|[nN][oO]) return 1 ;; *) return 0 ;; esac
|
||
else
|
||
read -rp " $prompt_text [y/N]: " input
|
||
case "${input:-n}" in
|
||
[yY]|[yY][eE][sS]) return 0 ;; *) return 1 ;; esac
|
||
fi
|
||
}
|
||
|
||
generate_password() {
|
||
openssl rand -base64 24 | tr -d '/+=' | head -c 32
|
||
}
|
||
|
||
# --- Argument parsing ---
|
||
|
||
parse_args() {
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--silent) MODE="silent" ;;
|
||
--expert) MODE="expert" ;;
|
||
--config) CONFIG_FILE_PATH="$2"; shift ;;
|
||
--install-dir) INSTALL_DIR="$2"; shift ;;
|
||
--public-host) PUBLIC_HOST="$2"; shift ;;
|
||
--public-protocol) PUBLIC_PROTOCOL="$2"; shift ;;
|
||
--admin-user) ADMIN_USER="$2"; shift ;;
|
||
--admin-password) ADMIN_PASS="$2"; shift ;;
|
||
--tls-mode) TLS_MODE="$2"; shift ;;
|
||
--cert-file) CERT_FILE="$2"; shift ;;
|
||
--key-file) KEY_FILE="$2"; shift ;;
|
||
--ca-file) CA_FILE="$2"; shift ;;
|
||
--postgres-password) POSTGRES_PASSWORD="$2"; shift ;;
|
||
--clickhouse-password) CLICKHOUSE_PASSWORD="$2"; shift ;;
|
||
--http-port) HTTP_PORT="$2"; shift ;;
|
||
--https-port) HTTPS_PORT="$2"; shift ;;
|
||
--logto-console-port) LOGTO_CONSOLE_PORT="$2"; shift ;;
|
||
--logto-console-exposed) LOGTO_CONSOLE_EXPOSED="$2"; shift ;;
|
||
--vendor-enabled) VENDOR_ENABLED="$2"; shift ;;
|
||
--vendor-user) VENDOR_USER="$2"; shift ;;
|
||
--vendor-password) VENDOR_PASS="$2"; shift ;;
|
||
--monitoring-network) MONITORING_NETWORK="$2"; shift ;;
|
||
--version) VERSION="$2"; shift ;;
|
||
--compose-project) COMPOSE_PROJECT="$2"; shift ;;
|
||
--docker-socket) DOCKER_SOCKET="$2"; shift ;;
|
||
--node-tls-reject) NODE_TLS_REJECT="$2"; shift ;;
|
||
--tenant-org-name) TENANT_ORG_NAME="$2"; shift ;;
|
||
--reconfigure) RERUN_ACTION="reconfigure" ;;
|
||
--reinstall) RERUN_ACTION="reinstall" ;;
|
||
--confirm-destroy) CONFIRM_DESTROY=true ;;
|
||
--help|-h) show_help; exit 0 ;;
|
||
*)
|
||
log_error "Unknown option: $1"
|
||
echo " Run with --help for usage."
|
||
exit 1
|
||
;;
|
||
esac
|
||
shift
|
||
done
|
||
}
|
||
|
||
show_help() {
|
||
echo "Usage: install.sh [OPTIONS]"
|
||
echo ""
|
||
echo "Modes:"
|
||
echo " (default) Interactive simple mode (6 questions)"
|
||
echo " --expert Interactive expert mode (all options)"
|
||
echo " --silent Non-interactive, use defaults + overrides"
|
||
echo ""
|
||
echo "Options:"
|
||
echo " --install-dir DIR Install directory (default: ./cameleer)"
|
||
echo " --public-host HOST Public hostname (default: auto-detect)"
|
||
echo " --admin-user USER Admin username (default: admin)"
|
||
echo " --admin-password PASS Admin password (default: generated)"
|
||
echo " --tls-mode MODE self-signed or custom (default: self-signed)"
|
||
echo " --cert-file PATH TLS certificate file"
|
||
echo " --key-file PATH TLS key file"
|
||
echo " --ca-file PATH CA bundle file"
|
||
echo " --monitoring-network NAME Docker network for Prometheus scraping"
|
||
echo " --version TAG Image version tag (default: latest)"
|
||
echo " --config FILE Load config from file"
|
||
echo " --help Show this help"
|
||
echo ""
|
||
echo "Expert options:"
|
||
echo " --postgres-password, --clickhouse-password, --http-port,"
|
||
echo " --https-port, --logto-console-port, --logto-console-exposed,"
|
||
echo " --vendor-enabled, --vendor-user, --vendor-password,"
|
||
echo " --compose-project, --docker-socket, --node-tls-reject"
|
||
echo ""
|
||
echo "Re-run options:"
|
||
echo " --reconfigure Re-run interactive setup (preserve data)"
|
||
echo " --reinstall --confirm-destroy Fresh install (destroys data)"
|
||
echo ""
|
||
echo "Config precedence: CLI flags > env vars > config file > defaults"
|
||
}
|
||
|
||
# --- Config file handling ---
|
||
|
||
load_config_file() {
|
||
local file="$1"
|
||
[ ! -f "$file" ] && return
|
||
while IFS='=' read -r key value; do
|
||
case "$key" in
|
||
\#*|"") continue ;;
|
||
esac
|
||
key=$(echo "$key" | tr -d ' ')
|
||
value=$(echo "$value" | sed 's/^[ ]*//;s/[ ]*$//')
|
||
case "$key" in
|
||
install_dir) [ -z "$INSTALL_DIR" ] && INSTALL_DIR="$value" ;;
|
||
public_host) [ -z "$PUBLIC_HOST" ] && PUBLIC_HOST="$value" ;;
|
||
public_protocol) [ -z "$PUBLIC_PROTOCOL" ] && PUBLIC_PROTOCOL="$value" ;;
|
||
admin_user) [ -z "$ADMIN_USER" ] && ADMIN_USER="$value" ;;
|
||
admin_password) [ -z "$ADMIN_PASS" ] && ADMIN_PASS="$value" ;;
|
||
tls_mode) [ -z "$TLS_MODE" ] && TLS_MODE="$value" ;;
|
||
cert_file) [ -z "$CERT_FILE" ] && CERT_FILE="$value" ;;
|
||
key_file) [ -z "$KEY_FILE" ] && KEY_FILE="$value" ;;
|
||
ca_file) [ -z "$CA_FILE" ] && CA_FILE="$value" ;;
|
||
postgres_password) [ -z "$POSTGRES_PASSWORD" ] && POSTGRES_PASSWORD="$value" ;;
|
||
clickhouse_password) [ -z "$CLICKHOUSE_PASSWORD" ] && CLICKHOUSE_PASSWORD="$value" ;;
|
||
http_port) [ -z "$HTTP_PORT" ] && HTTP_PORT="$value" ;;
|
||
https_port) [ -z "$HTTPS_PORT" ] && HTTPS_PORT="$value" ;;
|
||
logto_console_port) [ -z "$LOGTO_CONSOLE_PORT" ] && LOGTO_CONSOLE_PORT="$value" ;;
|
||
logto_console_exposed) [ -z "$LOGTO_CONSOLE_EXPOSED" ] && LOGTO_CONSOLE_EXPOSED="$value" ;;
|
||
vendor_enabled) [ -z "$VENDOR_ENABLED" ] && VENDOR_ENABLED="$value" ;;
|
||
vendor_user) [ -z "$VENDOR_USER" ] && VENDOR_USER="$value" ;;
|
||
vendor_password) [ -z "$VENDOR_PASS" ] && VENDOR_PASS="$value" ;;
|
||
monitoring_network) [ -z "$MONITORING_NETWORK" ] && MONITORING_NETWORK="$value" ;;
|
||
version) [ -z "$VERSION" ] && VERSION="$value" ;;
|
||
compose_project) [ -z "$COMPOSE_PROJECT" ] && COMPOSE_PROJECT="$value" ;;
|
||
docker_socket) [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$value" ;;
|
||
node_tls_reject) [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$value" ;;
|
||
tenant_org_name) [ -z "$TENANT_ORG_NAME" ] && TENANT_ORG_NAME="$value" ;;
|
||
esac
|
||
done < "$file"
|
||
}
|
||
|
||
load_env_overrides() {
|
||
[ -z "$INSTALL_DIR" ] && INSTALL_DIR="${CAMELEER_INSTALL_DIR:-}"
|
||
[ -z "$PUBLIC_HOST" ] && PUBLIC_HOST="$_ENV_PUBLIC_HOST"
|
||
[ -z "$PUBLIC_PROTOCOL" ] && PUBLIC_PROTOCOL="$_ENV_PUBLIC_PROTOCOL"
|
||
[ -z "$ADMIN_USER" ] && ADMIN_USER="${SAAS_ADMIN_USER:-}"
|
||
[ -z "$ADMIN_PASS" ] && ADMIN_PASS="${SAAS_ADMIN_PASS:-}"
|
||
[ -z "$TLS_MODE" ] && TLS_MODE="$_ENV_TLS_MODE"
|
||
[ -z "$CERT_FILE" ] && CERT_FILE="$_ENV_CERT_FILE"
|
||
[ -z "$KEY_FILE" ] && KEY_FILE="$_ENV_KEY_FILE"
|
||
[ -z "$CA_FILE" ] && CA_FILE="$_ENV_CA_FILE"
|
||
[ -z "$POSTGRES_PASSWORD" ] && POSTGRES_PASSWORD="$_ENV_POSTGRES_PASSWORD"
|
||
[ -z "$CLICKHOUSE_PASSWORD" ] && CLICKHOUSE_PASSWORD="$_ENV_CLICKHOUSE_PASSWORD"
|
||
[ -z "$HTTP_PORT" ] && HTTP_PORT="$_ENV_HTTP_PORT"
|
||
[ -z "$HTTPS_PORT" ] && HTTPS_PORT="$_ENV_HTTPS_PORT"
|
||
[ -z "$LOGTO_CONSOLE_PORT" ] && LOGTO_CONSOLE_PORT="$_ENV_LOGTO_CONSOLE_PORT"
|
||
[ -z "$LOGTO_CONSOLE_EXPOSED" ] && LOGTO_CONSOLE_EXPOSED="$_ENV_LOGTO_CONSOLE_EXPOSED"
|
||
[ -z "$VENDOR_ENABLED" ] && VENDOR_ENABLED="$_ENV_VENDOR_ENABLED"
|
||
[ -z "$VENDOR_USER" ] && VENDOR_USER="$_ENV_VENDOR_USER"
|
||
[ -z "$VENDOR_PASS" ] && VENDOR_PASS="$_ENV_VENDOR_PASS"
|
||
[ -z "$MONITORING_NETWORK" ] && MONITORING_NETWORK="$_ENV_MONITORING_NETWORK"
|
||
[ -z "$VERSION" ] && VERSION="${CAMELEER_VERSION:-}"
|
||
[ -z "$COMPOSE_PROJECT" ] && COMPOSE_PROJECT="$_ENV_COMPOSE_PROJECT"
|
||
[ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$_ENV_DOCKER_SOCKET"
|
||
[ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$_ENV_NODE_TLS_REJECT"
|
||
}
|
||
|
||
# --- Prerequisites ---
|
||
|
||
check_prerequisites() {
|
||
log_info "Checking prerequisites..."
|
||
local errors=0
|
||
|
||
if ! command -v docker >/dev/null 2>&1; then
|
||
log_error "Docker is not installed."
|
||
echo " Install Docker Engine: https://docs.docker.com/engine/install/"
|
||
errors=$((errors + 1))
|
||
else
|
||
local docker_version
|
||
docker_version=$(docker version --format '{{.Server.Version}}' 2>/dev/null || echo "unknown")
|
||
log_info "Docker version: $docker_version"
|
||
fi
|
||
|
||
if ! docker compose version >/dev/null 2>&1; then
|
||
log_error "Docker Compose v2 is not available."
|
||
echo " 'docker compose' subcommand required (not standalone docker-compose)."
|
||
errors=$((errors + 1))
|
||
else
|
||
local compose_version
|
||
compose_version=$(docker compose version --short 2>/dev/null || echo "unknown")
|
||
log_info "Docker Compose version: $compose_version"
|
||
fi
|
||
|
||
if ! command -v openssl >/dev/null 2>&1; then
|
||
log_error "OpenSSL is not installed (needed for password generation)."
|
||
errors=$((errors + 1))
|
||
fi
|
||
|
||
local socket="${DOCKER_SOCKET:-$DEFAULT_DOCKER_SOCKET}"
|
||
if [ ! -S "$socket" ]; then
|
||
log_warn "Docker socket not found at $socket"
|
||
fi
|
||
|
||
check_port_available "${HTTP_PORT:-$DEFAULT_HTTP_PORT}" "HTTP"
|
||
check_port_available "${HTTPS_PORT:-$DEFAULT_HTTPS_PORT}" "HTTPS"
|
||
check_port_available "${LOGTO_CONSOLE_PORT:-$DEFAULT_LOGTO_CONSOLE_PORT}" "Logto Console"
|
||
|
||
if [ $errors -gt 0 ]; then
|
||
log_error "$errors prerequisite(s) not met. Please install missing dependencies and retry."
|
||
exit 1
|
||
fi
|
||
log_success "All prerequisites met."
|
||
}
|
||
|
||
check_port_available() {
|
||
local port="$1" name="$2"
|
||
if command -v ss >/dev/null 2>&1; then
|
||
if ss -tlnp 2>/dev/null | grep -q ":${port} "; then
|
||
log_warn "Port $port ($name) is already in use."
|
||
fi
|
||
elif command -v netstat >/dev/null 2>&1; then
|
||
if netstat -tlnp 2>/dev/null | grep -q ":${port} "; then
|
||
log_warn "Port $port ($name) is already in use."
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# --- Auto-detection ---
|
||
|
||
auto_detect() {
|
||
if [ -z "$PUBLIC_HOST" ]; then
|
||
PUBLIC_HOST=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "localhost")
|
||
# Normalize to lowercase (Windows hostnames are uppercase)
|
||
PUBLIC_HOST=$(echo "$PUBLIC_HOST" | tr '[:upper:]' '[:lower:]')
|
||
local primary_ip
|
||
primary_ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' || true)
|
||
if [ -n "$primary_ip" ]; then
|
||
local rdns
|
||
rdns=$(dig +short -x "$primary_ip" 2>/dev/null | sed 's/\.$//' || true)
|
||
[ -n "$rdns" ] && PUBLIC_HOST="$rdns"
|
||
fi
|
||
fi
|
||
|
||
if [ -z "$DOCKER_SOCKET" ]; then
|
||
DOCKER_SOCKET="$DEFAULT_DOCKER_SOCKET"
|
||
fi
|
||
}
|
||
|
||
detect_existing_install() {
|
||
local dir="${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}"
|
||
if [ -f "$dir/cameleer.conf" ]; then
|
||
IS_RERUN=true
|
||
INSTALL_DIR="$dir"
|
||
load_config_file "$dir/cameleer.conf"
|
||
fi
|
||
}
|
||
|
||
# --- Interactive prompts ---
|
||
|
||
select_mode() {
|
||
if [ -n "$MODE" ]; then return; fi
|
||
echo ""
|
||
echo " Installation mode:"
|
||
echo " [1] Simple — 6 questions, sensible defaults (recommended)"
|
||
echo " [2] Expert — configure everything"
|
||
echo ""
|
||
local choice
|
||
read -rp " Select mode [1]: " choice
|
||
case "${choice:-1}" in
|
||
2) MODE="expert" ;;
|
||
*) MODE="simple" ;;
|
||
esac
|
||
}
|
||
|
||
run_simple_prompts() {
|
||
echo ""
|
||
echo -e "${BOLD}--- Simple Installation ---${NC}"
|
||
echo ""
|
||
|
||
prompt INSTALL_DIR "Install directory" "${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}"
|
||
prompt PUBLIC_HOST "Public hostname" "${PUBLIC_HOST:-localhost}"
|
||
prompt ADMIN_USER "Admin username" "${ADMIN_USER:-$DEFAULT_ADMIN_USER}"
|
||
|
||
if prompt_yesno "Auto-generate admin password?" "y"; then
|
||
ADMIN_PASS=""
|
||
else
|
||
prompt_password ADMIN_PASS "Admin password" ""
|
||
fi
|
||
|
||
echo ""
|
||
if prompt_yesno "Use custom TLS certificates? (no = self-signed)"; then
|
||
TLS_MODE="custom"
|
||
prompt CERT_FILE "Path to certificate file (PEM)" ""
|
||
prompt KEY_FILE "Path to private key file (PEM)" ""
|
||
if prompt_yesno "Include CA bundle?"; then
|
||
prompt CA_FILE "Path to CA bundle (PEM)" ""
|
||
fi
|
||
else
|
||
TLS_MODE="self-signed"
|
||
fi
|
||
|
||
echo ""
|
||
prompt MONITORING_NETWORK "Monitoring network name (empty = skip)" ""
|
||
|
||
echo ""
|
||
echo " Deployment mode:"
|
||
echo " [1] Multi-tenant vendor — admin manages platform, creates tenants on demand"
|
||
echo " [2] Single tenant — set up one tenant for immediate use"
|
||
echo ""
|
||
local deploy_choice
|
||
read -rp " Select mode [1]: " deploy_choice
|
||
case "${deploy_choice:-1}" in
|
||
2)
|
||
VENDOR_ENABLED="false"
|
||
prompt TENANT_ORG_NAME "Organization / tenant name" ""
|
||
;;
|
||
*)
|
||
VENDOR_ENABLED="true"
|
||
TENANT_ORG_NAME=""
|
||
;;
|
||
esac
|
||
}
|
||
|
||
run_expert_prompts() {
|
||
echo ""
|
||
echo -e "${BOLD}--- Expert Installation ---${NC}"
|
||
|
||
run_simple_prompts
|
||
|
||
echo ""
|
||
echo -e "${BOLD} Credentials:${NC}"
|
||
if prompt_yesno "Auto-generate database passwords?" "y"; then
|
||
POSTGRES_PASSWORD=""
|
||
CLICKHOUSE_PASSWORD=""
|
||
else
|
||
prompt_password POSTGRES_PASSWORD "PostgreSQL password" ""
|
||
prompt_password CLICKHOUSE_PASSWORD "ClickHouse password" ""
|
||
fi
|
||
|
||
echo ""
|
||
if prompt_yesno "Enable vendor account?"; then
|
||
VENDOR_ENABLED="true"
|
||
prompt VENDOR_USER "Vendor username" "${VENDOR_USER:-$DEFAULT_VENDOR_USER}"
|
||
if prompt_yesno "Auto-generate vendor password?" "y"; then
|
||
VENDOR_PASS=""
|
||
else
|
||
prompt_password VENDOR_PASS "Vendor password" ""
|
||
fi
|
||
else
|
||
VENDOR_ENABLED="false"
|
||
fi
|
||
|
||
echo ""
|
||
echo -e "${BOLD} Networking:${NC}"
|
||
prompt HTTP_PORT "HTTP port" "${HTTP_PORT:-$DEFAULT_HTTP_PORT}"
|
||
prompt HTTPS_PORT "HTTPS port" "${HTTPS_PORT:-$DEFAULT_HTTPS_PORT}"
|
||
prompt LOGTO_CONSOLE_PORT "Logto admin console port" "${LOGTO_CONSOLE_PORT:-$DEFAULT_LOGTO_CONSOLE_PORT}"
|
||
|
||
echo ""
|
||
echo -e "${BOLD} Docker:${NC}"
|
||
prompt VERSION "Image version/tag" "${VERSION:-$CAMELEER_DEFAULT_VERSION}"
|
||
prompt COMPOSE_PROJECT "Compose project name" "${COMPOSE_PROJECT:-$DEFAULT_COMPOSE_PROJECT}"
|
||
prompt DOCKER_SOCKET "Docker socket path" "${DOCKER_SOCKET:-$DEFAULT_DOCKER_SOCKET}"
|
||
|
||
echo ""
|
||
echo -e "${BOLD} Logto:${NC}"
|
||
if prompt_yesno "Expose Logto admin console externally?" "y"; then
|
||
LOGTO_CONSOLE_EXPOSED="true"
|
||
else
|
||
LOGTO_CONSOLE_EXPOSED="false"
|
||
fi
|
||
}
|
||
|
||
# --- Config merge and validation ---
|
||
|
||
merge_config() {
|
||
: "${INSTALL_DIR:=$DEFAULT_INSTALL_DIR}"
|
||
: "${PUBLIC_HOST:=localhost}"
|
||
: "${PUBLIC_PROTOCOL:=$DEFAULT_PUBLIC_PROTOCOL}"
|
||
: "${ADMIN_USER:=$DEFAULT_ADMIN_USER}"
|
||
: "${TLS_MODE:=$DEFAULT_TLS_MODE}"
|
||
: "${HTTP_PORT:=$DEFAULT_HTTP_PORT}"
|
||
: "${HTTPS_PORT:=$DEFAULT_HTTPS_PORT}"
|
||
: "${LOGTO_CONSOLE_PORT:=$DEFAULT_LOGTO_CONSOLE_PORT}"
|
||
: "${LOGTO_CONSOLE_EXPOSED:=$DEFAULT_LOGTO_CONSOLE_EXPOSED}"
|
||
: "${VENDOR_ENABLED:=$DEFAULT_VENDOR_ENABLED}"
|
||
: "${VENDOR_USER:=$DEFAULT_VENDOR_USER}"
|
||
: "${VERSION:=$CAMELEER_DEFAULT_VERSION}"
|
||
: "${COMPOSE_PROJECT:=$DEFAULT_COMPOSE_PROJECT}"
|
||
: "${DOCKER_SOCKET:=$DEFAULT_DOCKER_SOCKET}"
|
||
|
||
# Force lowercase hostname — Logto normalizes internally, case mismatch breaks JWT validation
|
||
PUBLIC_HOST=$(echo "$PUBLIC_HOST" | tr '[:upper:]' '[:lower:]')
|
||
|
||
if [ -z "$NODE_TLS_REJECT" ]; then
|
||
if [ "$TLS_MODE" = "custom" ]; then
|
||
NODE_TLS_REJECT="1"
|
||
else
|
||
NODE_TLS_REJECT="0"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
validate_config() {
|
||
local errors=0
|
||
|
||
if [ "$TLS_MODE" = "custom" ]; then
|
||
if [ ! -f "$CERT_FILE" ]; then
|
||
log_error "Certificate file not found: $CERT_FILE"
|
||
errors=$((errors + 1))
|
||
fi
|
||
if [ ! -f "$KEY_FILE" ]; then
|
||
log_error "Key file not found: $KEY_FILE"
|
||
errors=$((errors + 1))
|
||
fi
|
||
if [ -n "$CA_FILE" ] && [ ! -f "$CA_FILE" ]; then
|
||
log_error "CA bundle not found: $CA_FILE"
|
||
errors=$((errors + 1))
|
||
fi
|
||
fi
|
||
|
||
for port_var in HTTP_PORT HTTPS_PORT LOGTO_CONSOLE_PORT; do
|
||
local port_val
|
||
eval "port_val=\$$port_var"
|
||
if ! echo "$port_val" | grep -qE '^[0-9]+$' || [ "$port_val" -lt 1 ] || [ "$port_val" -gt 65535 ]; then
|
||
log_error "Invalid port for $port_var: $port_val"
|
||
errors=$((errors + 1))
|
||
fi
|
||
done
|
||
|
||
if [ $errors -gt 0 ]; then
|
||
log_error "Configuration validation failed."
|
||
exit 1
|
||
fi
|
||
log_success "Configuration validated."
|
||
}
|
||
|
||
generate_passwords() {
|
||
if [ -z "$ADMIN_PASS" ]; then
|
||
ADMIN_PASS=$(generate_password)
|
||
log_info "Generated admin password."
|
||
fi
|
||
if [ -z "$POSTGRES_PASSWORD" ]; then
|
||
POSTGRES_PASSWORD=$(generate_password)
|
||
log_info "Generated PostgreSQL password."
|
||
fi
|
||
if [ -z "$CLICKHOUSE_PASSWORD" ]; then
|
||
CLICKHOUSE_PASSWORD=$(generate_password)
|
||
log_info "Generated ClickHouse password."
|
||
fi
|
||
if [ "$VENDOR_ENABLED" = "true" ] && [ -z "$VENDOR_PASS" ]; then
|
||
VENDOR_PASS=$(generate_password)
|
||
log_info "Generated vendor password."
|
||
fi
|
||
}
|
||
|
||
# --- File generation ---
|
||
|
||
copy_certs() {
|
||
local certs_dir="$INSTALL_DIR/certs"
|
||
mkdir -p "$certs_dir"
|
||
cp "$CERT_FILE" "$certs_dir/cert.pem"
|
||
cp "$KEY_FILE" "$certs_dir/key.pem"
|
||
if [ -n "$CA_FILE" ]; then
|
||
cp "$CA_FILE" "$certs_dir/ca.pem"
|
||
fi
|
||
log_info "Copied TLS certificates to $certs_dir/"
|
||
}
|
||
|
||
generate_env_file() {
|
||
local f="$INSTALL_DIR/.env"
|
||
cat > "$f" << EOF
|
||
# Cameleer SaaS Configuration
|
||
# Generated by installer v${CAMELEER_INSTALLER_VERSION} on $(date -u '+%Y-%m-%d %H:%M:%S UTC')
|
||
|
||
# Image version
|
||
VERSION=${VERSION}
|
||
|
||
# Public access
|
||
PUBLIC_HOST=${PUBLIC_HOST}
|
||
PUBLIC_PROTOCOL=${PUBLIC_PROTOCOL}
|
||
|
||
# Ports
|
||
HTTP_PORT=${HTTP_PORT}
|
||
HTTPS_PORT=${HTTPS_PORT}
|
||
LOGTO_CONSOLE_PORT=${LOGTO_CONSOLE_PORT}
|
||
|
||
# PostgreSQL
|
||
POSTGRES_USER=cameleer
|
||
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||
POSTGRES_DB=cameleer_saas
|
||
|
||
# ClickHouse
|
||
CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD}
|
||
|
||
# Admin user
|
||
SAAS_ADMIN_USER=${ADMIN_USER}
|
||
SAAS_ADMIN_PASS=${ADMIN_PASS}
|
||
|
||
# TLS
|
||
NODE_TLS_REJECT=${NODE_TLS_REJECT}
|
||
EOF
|
||
|
||
if [ "$TLS_MODE" = "custom" ]; then
|
||
cat >> "$f" << 'EOF'
|
||
CERT_FILE=/user-certs/cert.pem
|
||
KEY_FILE=/user-certs/key.pem
|
||
EOF
|
||
if [ -n "$CA_FILE" ]; then
|
||
echo "CA_FILE=/user-certs/ca.pem" >> "$f"
|
||
fi
|
||
fi
|
||
|
||
cat >> "$f" << EOF
|
||
|
||
# Vendor account
|
||
VENDOR_SEED_ENABLED=${VENDOR_ENABLED}
|
||
VENDOR_USER=${VENDOR_USER}
|
||
VENDOR_PASS=${VENDOR_PASS:-}
|
||
|
||
# Single-tenant org (when vendor is disabled)
|
||
TENANT_ORG_NAME=${TENANT_ORG_NAME:-}
|
||
|
||
# Docker
|
||
DOCKER_SOCKET=${DOCKER_SOCKET}
|
||
DOCKER_GID=$(stat -c '%g' "${DOCKER_SOCKET}" 2>/dev/null || echo "0")
|
||
|
||
# Provisioning images
|
||
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=${REGISTRY}/cameleer3-server:${VERSION}
|
||
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=${REGISTRY}/cameleer3-server-ui:${VERSION}
|
||
EOF
|
||
|
||
log_info "Generated .env"
|
||
cp "$f" "$INSTALL_DIR/.env.bak"
|
||
}
|
||
|
||
generate_compose_file() {
|
||
local f="$INSTALL_DIR/docker-compose.yml"
|
||
: > "$f"
|
||
|
||
cat >> "$f" << 'EOF'
|
||
# Cameleer SaaS Platform
|
||
# Generated by Cameleer installer <20> do not edit manually
|
||
|
||
services:
|
||
traefik:
|
||
image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest}
|
||
restart: unless-stopped
|
||
ports:
|
||
- "${HTTP_PORT:-80}:80"
|
||
- "${HTTPS_PORT:-443}:443"
|
||
EOF
|
||
|
||
if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
|
||
cat >> "$f" << 'EOF'
|
||
- "${LOGTO_CONSOLE_PORT:-3002}:3002"
|
||
EOF
|
||
fi
|
||
|
||
cat >> "$f" << 'EOF'
|
||
environment:
|
||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
||
CERT_FILE: ${CERT_FILE:-}
|
||
KEY_FILE: ${KEY_FILE:-}
|
||
CA_FILE: ${CA_FILE:-}
|
||
volumes:
|
||
- certs:/certs
|
||
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro
|
||
EOF
|
||
|
||
if [ "$TLS_MODE" = "custom" ]; then
|
||
cat >> "$f" << 'EOF'
|
||
- ./certs:/user-certs:ro
|
||
EOF
|
||
fi
|
||
|
||
cat >> "$f" << 'EOF'
|
||
networks:
|
||
- cameleer
|
||
- cameleer-traefik
|
||
EOF
|
||
|
||
if [ -n "$MONITORING_NETWORK" ]; then
|
||
echo " - ${MONITORING_NETWORK}" >> "$f"
|
||
cat >> "$f" << 'EOF'
|
||
labels:
|
||
- "prometheus.io/scrape=true"
|
||
- "prometheus.io/port=8082"
|
||
- "prometheus.io/path=/metrics"
|
||
EOF
|
||
fi
|
||
|
||
cat >> "$f" << 'EOF'
|
||
|
||
postgres:
|
||
image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest}
|
||
restart: unless-stopped
|
||
environment:
|
||
POSTGRES_DB: cameleer_saas
|
||
POSTGRES_USER: ${POSTGRES_USER:-cameleer}
|
||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||
volumes:
|
||
- pgdata:/var/lib/postgresql/data
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d cameleer_saas"]
|
||
interval: 5s
|
||
timeout: 5s
|
||
retries: 5
|
||
networks:
|
||
- cameleer
|
||
EOF
|
||
|
||
if [ -n "$MONITORING_NETWORK" ]; then
|
||
echo " - ${MONITORING_NETWORK}" >> "$f"
|
||
fi
|
||
|
||
cat >> "$f" << 'EOF'
|
||
|
||
clickhouse:
|
||
image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest}
|
||
restart: unless-stopped
|
||
environment:
|
||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
|
||
volumes:
|
||
- chdata:/var/lib/clickhouse
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "clickhouse-client --password $${CLICKHOUSE_PASSWORD} --query 'SELECT 1'"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 3
|
||
networks:
|
||
- cameleer
|
||
EOF
|
||
|
||
if [ -n "$MONITORING_NETWORK" ]; then
|
||
echo " - ${MONITORING_NETWORK}" >> "$f"
|
||
cat >> "$f" << 'EOF'
|
||
labels:
|
||
- "prometheus.io/scrape=true"
|
||
- "prometheus.io/port=9363"
|
||
- "prometheus.io/path=/metrics"
|
||
EOF
|
||
fi
|
||
|
||
cat >> "$f" << 'EOF'
|
||
|
||
logto:
|
||
image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest}
|
||
restart: unless-stopped
|
||
depends_on:
|
||
postgres:
|
||
condition: service_healthy
|
||
environment:
|
||
DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD}@postgres:5432/logto
|
||
ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||
ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}
|
||
TRUST_PROXY_HEADER: 1
|
||
NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}"
|
||
LOGTO_ENDPOINT: http://logto:3001
|
||
LOGTO_ADMIN_ENDPOINT: http://logto:3002
|
||
LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||
PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
|
||
PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
||
PG_HOST: postgres
|
||
PG_USER: ${POSTGRES_USER:-cameleer}
|
||
PG_PASSWORD: ${POSTGRES_PASSWORD}
|
||
PG_DB_SAAS: cameleer_saas
|
||
SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin}
|
||
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin}
|
||
VENDOR_SEED_ENABLED: "${VENDOR_SEED_ENABLED:-false}"
|
||
VENDOR_USER: ${VENDOR_USER:-vendor}
|
||
VENDOR_PASS: ${VENDOR_PASS:-vendor}
|
||
TENANT_ORG_NAME: ${TENANT_ORG_NAME:-}
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 60
|
||
start_period: 30s
|
||
labels:
|
||
- traefik.enable=true
|
||
- traefik.http.routers.logto.rule=PathPrefix(`/`)
|
||
- traefik.http.routers.logto.priority=1
|
||
- traefik.http.routers.logto.entrypoints=websecure
|
||
- traefik.http.routers.logto.tls=true
|
||
- traefik.http.routers.logto.service=logto
|
||
- traefik.http.routers.logto.middlewares=logto-cors
|
||
- "traefik.http.middlewares.logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}"
|
||
- traefik.http.middlewares.logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS
|
||
- traefik.http.middlewares.logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type
|
||
- traefik.http.middlewares.logto-cors.headers.accessControlAllowCredentials=true
|
||
- traefik.http.services.logto.loadbalancer.server.port=3001
|
||
EOF
|
||
|
||
if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
|
||
cat >> "$f" << 'EOF'
|
||
- traefik.http.routers.logto-console.rule=PathPrefix(`/`)
|
||
- traefik.http.routers.logto-console.entrypoints=admin-console
|
||
- traefik.http.routers.logto-console.tls=true
|
||
- traefik.http.routers.logto-console.service=logto-console
|
||
- traefik.http.services.logto-console.loadbalancer.server.port=3002
|
||
EOF
|
||
fi
|
||
|
||
cat >> "$f" << 'EOF'
|
||
volumes:
|
||
- bootstrapdata:/data
|
||
networks:
|
||
- cameleer
|
||
|
||
cameleer-saas:
|
||
image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
|
||
restart: unless-stopped
|
||
depends_on:
|
||
logto:
|
||
condition: service_healthy
|
||
environment:
|
||
DOCKER_HOST: unix:///var/run/docker.sock
|
||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/cameleer_saas
|
||
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
|
||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||
CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: http://logto:3001
|
||
CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}
|
||
CAMELEER_SAAS_PROVISIONING_DATASOURCEUSERNAME: ${POSTGRES_USER:-cameleer}
|
||
CAMELEER_SAAS_PROVISIONING_DATASOURCEPASSWORD: ${POSTGRES_PASSWORD}
|
||
CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https}
|
||
CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost}
|
||
CAMELEER_SAAS_PROVISIONING_NETWORKNAME: ${COMPOSE_PROJECT_NAME:-cameleer-saas}_cameleer
|
||
CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK: cameleer-traefik
|
||
CAMELEER_SAAS_PROVISIONING_SERVERIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:-gitea.siegeln.net/cameleer/cameleer3-server:latest}
|
||
CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:-gitea.siegeln.net/cameleer/cameleer3-server-ui:latest}
|
||
labels:
|
||
- traefik.enable=true
|
||
- traefik.http.routers.saas.rule=PathPrefix(`/platform`)
|
||
- traefik.http.routers.saas.entrypoints=websecure
|
||
- traefik.http.routers.saas.tls=true
|
||
- traefik.http.services.saas.loadbalancer.server.port=8080
|
||
EOF
|
||
|
||
if [ -n "$MONITORING_NETWORK" ]; then
|
||
cat >> "$f" << 'EOF'
|
||
- "prometheus.io/scrape=true"
|
||
- "prometheus.io/port=8080"
|
||
- "prometheus.io/path=/platform/actuator/prometheus"
|
||
EOF
|
||
fi
|
||
|
||
cat >> "$f" << 'EOF'
|
||
volumes:
|
||
- bootstrapdata:/data/bootstrap:ro
|
||
- certs:/certs
|
||
- ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock
|
||
networks:
|
||
- cameleer
|
||
EOF
|
||
|
||
if [ -n "$MONITORING_NETWORK" ]; then
|
||
echo " - ${MONITORING_NETWORK}" >> "$f"
|
||
fi
|
||
|
||
# Detect Docker socket GID for container access
|
||
local docker_gid
|
||
docker_gid=$(stat -c '%g' "${DOCKER_SOCKET:-/var/run/docker.sock}" 2>/dev/null || echo "0")
|
||
cat >> "$f" << EOF
|
||
group_add:
|
||
- "${docker_gid}"
|
||
|
||
volumes:
|
||
EOF
|
||
cat >> "$f" << 'EOF'
|
||
pgdata:
|
||
chdata:
|
||
certs:
|
||
bootstrapdata:
|
||
|
||
networks:
|
||
cameleer:
|
||
driver: bridge
|
||
cameleer-traefik:
|
||
name: cameleer-traefik
|
||
driver: bridge
|
||
EOF
|
||
|
||
if [ -n "$MONITORING_NETWORK" ]; then
|
||
cat >> "$f" << EOF
|
||
${MONITORING_NETWORK}:
|
||
external: true
|
||
EOF
|
||
fi
|
||
|
||
log_info "Generated docker-compose.yml"
|
||
}
|
||
|
||
# --- Docker operations ---
|
||
|
||
docker_compose_pull() {
|
||
log_info "Pulling Docker images..."
|
||
(cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" pull)
|
||
log_success "All images pulled."
|
||
}
|
||
|
||
docker_compose_up() {
|
||
log_info "Starting Cameleer SaaS platform..."
|
||
(cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" up -d)
|
||
log_info "Containers started."
|
||
}
|
||
|
||
docker_compose_down() {
|
||
log_info "Stopping Cameleer SaaS platform..."
|
||
(cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" down)
|
||
}
|
||
|
||
# --- Health verification ---
|
||
|
||
wait_for_docker_healthy() {
|
||
local name="$1" service="$2" timeout_secs="${3:-300}"
|
||
local start_time=$(date +%s)
|
||
|
||
while true; do
|
||
local elapsed=$(( $(date +%s) - start_time ))
|
||
if [ $elapsed -ge $timeout_secs ]; then
|
||
printf " ${RED}[FAIL]${NC} %-20s not healthy after %ds\n" "$name" "$timeout_secs"
|
||
echo " Check: docker compose -p $COMPOSE_PROJECT logs $service"
|
||
return 1
|
||
fi
|
||
local health
|
||
health=$(cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" ps "$service" --format '{{.Health}}' 2>/dev/null || echo "unknown")
|
||
case "$health" in
|
||
healthy)
|
||
local duration=$(( $(date +%s) - start_time ))
|
||
printf " ${GREEN}[ok]${NC} %-20s ready (%ds)\n" "$name" "$duration"
|
||
return 0
|
||
;;
|
||
unhealthy)
|
||
printf " ${RED}[FAIL]${NC} %-20s unhealthy\n" "$name"
|
||
echo " Check: docker compose -p $COMPOSE_PROJECT logs $service"
|
||
return 1
|
||
;;
|
||
*)
|
||
sleep 3
|
||
;;
|
||
esac
|
||
done
|
||
}
|
||
|
||
check_endpoint() {
|
||
local name="$1" url="$2" timeout_secs="${3:-120}"
|
||
local start_time=$(date +%s)
|
||
|
||
while true; do
|
||
local elapsed=$(( $(date +%s) - start_time ))
|
||
if [ $elapsed -ge $timeout_secs ]; then
|
||
printf " ${RED}[FAIL]${NC} %-20s not reachable after %ds\n" "$name" "$timeout_secs"
|
||
return 1
|
||
fi
|
||
if curl -sfk -o /dev/null "$url" 2>/dev/null; then
|
||
local duration=$(( $(date +%s) - start_time ))
|
||
printf " ${GREEN}[ok]${NC} %-20s ready (%ds)\n" "$name" "$duration"
|
||
return 0
|
||
fi
|
||
sleep 3
|
||
done
|
||
}
|
||
|
||
verify_health() {
|
||
echo ""
|
||
log_info "Verifying installation..."
|
||
local failed=0
|
||
|
||
wait_for_docker_healthy "PostgreSQL" "postgres" 120 || failed=1
|
||
|
||
[ $failed -eq 0 ] && \
|
||
wait_for_docker_healthy "ClickHouse" "clickhouse" 120 || failed=1
|
||
|
||
[ $failed -eq 0 ] && \
|
||
wait_for_docker_healthy "Logto + Bootstrap" "logto" 300 || failed=1
|
||
|
||
[ $failed -eq 0 ] && \
|
||
check_endpoint "Cameleer SaaS" "https://localhost:${HTTPS_PORT}/platform/api/config" 120 || failed=1
|
||
|
||
[ $failed -eq 0 ] && \
|
||
check_endpoint "Traefik routing" "https://localhost:${HTTPS_PORT}/" 30 || failed=1
|
||
|
||
echo ""
|
||
if [ $failed -ne 0 ]; then
|
||
log_error "Installation verification failed. Stack is running — check logs."
|
||
exit 1
|
||
fi
|
||
log_success "All services healthy."
|
||
}
|
||
|
||
# --- Output file generation ---
|
||
|
||
write_config_file() {
|
||
local f="$INSTALL_DIR/cameleer.conf"
|
||
cat > "$f" << EOF
|
||
# Cameleer installation config
|
||
# Generated by installer v${CAMELEER_INSTALLER_VERSION} on $(date -u '+%Y-%m-%d %H:%M:%S UTC')
|
||
|
||
install_dir=${INSTALL_DIR}
|
||
public_host=${PUBLIC_HOST}
|
||
public_protocol=${PUBLIC_PROTOCOL}
|
||
admin_user=${ADMIN_USER}
|
||
tls_mode=${TLS_MODE}
|
||
http_port=${HTTP_PORT}
|
||
https_port=${HTTPS_PORT}
|
||
logto_console_port=${LOGTO_CONSOLE_PORT}
|
||
logto_console_exposed=${LOGTO_CONSOLE_EXPOSED}
|
||
vendor_enabled=${VENDOR_ENABLED}
|
||
vendor_user=${VENDOR_USER}
|
||
monitoring_network=${MONITORING_NETWORK}
|
||
version=${VERSION}
|
||
compose_project=${COMPOSE_PROJECT}
|
||
docker_socket=${DOCKER_SOCKET}
|
||
node_tls_reject=${NODE_TLS_REJECT}
|
||
tenant_org_name=${TENANT_ORG_NAME}
|
||
EOF
|
||
log_info "Saved installer config to cameleer.conf"
|
||
}
|
||
|
||
generate_credentials_file() {
|
||
local f="$INSTALL_DIR/credentials.txt"
|
||
cat > "$f" << EOF
|
||
===========================================
|
||
CAMELEER PLATFORM CREDENTIALS
|
||
Generated: $(date -u '+%Y-%m-%d %H:%M:%S UTC')
|
||
|
||
SECURE THIS FILE AND DELETE AFTER NOTING
|
||
THESE CREDENTIALS CANNOT BE RECOVERED
|
||
===========================================
|
||
|
||
Admin Console: ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/platform/
|
||
Admin User: ${ADMIN_USER}
|
||
Admin Password: ${ADMIN_PASS}
|
||
|
||
PostgreSQL: cameleer / ${POSTGRES_PASSWORD}
|
||
ClickHouse: default / ${CLICKHOUSE_PASSWORD}
|
||
|
||
EOF
|
||
|
||
if [ "$VENDOR_ENABLED" = "true" ]; then
|
||
cat >> "$f" << EOF
|
||
Vendor User: ${VENDOR_USER}
|
||
Vendor Password: ${VENDOR_PASS}
|
||
|
||
EOF
|
||
else
|
||
echo "Vendor User: (not enabled)" >> "$f"
|
||
echo "" >> "$f"
|
||
fi
|
||
|
||
if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
|
||
echo "Logto Console: ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}:${LOGTO_CONSOLE_PORT}" >> "$f"
|
||
else
|
||
echo "Logto Console: (not exposed)" >> "$f"
|
||
fi
|
||
|
||
chmod 600 "$f"
|
||
log_info "Saved credentials to credentials.txt"
|
||
}
|
||
|
||
generate_install_doc() {
|
||
local f="$INSTALL_DIR/INSTALL.md"
|
||
local tls_desc="Self-signed (auto-generated)"
|
||
[ "$TLS_MODE" = "custom" ] && tls_desc="Custom certificate"
|
||
|
||
cat > "$f" << EOF
|
||
# Cameleer SaaS <20> Installation Documentation
|
||
|
||
## Installation Summary
|
||
|
||
| | |
|
||
|---|---|
|
||
| **Version** | ${VERSION} |
|
||
| **Date** | $(date -u '+%Y-%m-%d %H:%M:%S UTC') |
|
||
| **Installer** | v${CAMELEER_INSTALLER_VERSION} |
|
||
| **Install Directory** | ${INSTALL_DIR} |
|
||
| **Hostname** | ${PUBLIC_HOST} |
|
||
| **TLS** | ${tls_desc} |
|
||
|
||
## Service URLs
|
||
|
||
- **Platform UI:** ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/platform/
|
||
- **API Endpoint:** ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/platform/api/
|
||
EOF
|
||
|
||
if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
|
||
echo "- **Logto Admin Console:** ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}:${LOGTO_CONSOLE_PORT}" >> "$f"
|
||
fi
|
||
|
||
cat >> "$f" << 'EOF'
|
||
|
||
## First Steps
|
||
|
||
1. Open the Platform UI in your browser
|
||
2. Log in with the admin credentials from `credentials.txt`
|
||
3. Create your first tenant via the Vendor console
|
||
4. The platform will provision a dedicated server instance for the tenant
|
||
|
||
## Architecture
|
||
|
||
| Container | Purpose |
|
||
|---|---|
|
||
| `traefik` | Reverse proxy, TLS termination, routing |
|
||
| `postgres` | PostgreSQL database (SaaS + Logto + tenant schemas) |
|
||
| `clickhouse` | Time-series storage (traces, metrics, logs) |
|
||
| `logto` | OIDC identity provider + bootstrap |
|
||
| `cameleer-saas` | SaaS platform (Spring Boot + React) |
|
||
|
||
Per-tenant `cameleer3-server` and `cameleer3-server-ui` containers are provisioned dynamically when tenants are created.
|
||
|
||
## Networking
|
||
|
||
EOF
|
||
|
||
cat >> "$f" << EOF
|
||
| Port | Service |
|
||
|---|---|
|
||
| ${HTTP_PORT} | HTTP (redirects to HTTPS) |
|
||
| ${HTTPS_PORT} | HTTPS (main entry point) |
|
||
EOF
|
||
|
||
if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
|
||
echo "| ${LOGTO_CONSOLE_PORT} | Logto Admin Console |" >> "$f"
|
||
fi
|
||
|
||
if [ -n "$MONITORING_NETWORK" ]; then
|
||
cat >> "$f" << EOF
|
||
|
||
### Monitoring
|
||
|
||
Services are connected to the \`${MONITORING_NETWORK}\` Docker network with Prometheus labels for auto-discovery.
|
||
EOF
|
||
fi
|
||
|
||
cat >> "$f" << EOF
|
||
|
||
## TLS
|
||
|
||
**Mode:** ${tls_desc}
|
||
EOF
|
||
|
||
if [ "$TLS_MODE" = "self-signed" ]; then
|
||
cat >> "$f" << 'EOF'
|
||
|
||
The platform generated a self-signed certificate on first boot. To replace it:
|
||
1. Log in as admin and navigate to **Certificates** in the vendor console
|
||
2. Upload your certificate and key via the UI
|
||
3. Activate the new certificate (zero-downtime swap)
|
||
EOF
|
||
fi
|
||
|
||
cat >> "$f" << EOF
|
||
|
||
## Data & Backups
|
||
|
||
| Docker Volume | Contains |
|
||
|---|---|
|
||
| \`pgdata\` | PostgreSQL data (tenants, licenses, audit) |
|
||
| \`chdata\` | ClickHouse data (traces, metrics, logs) |
|
||
| \`certs\` | TLS certificates |
|
||
| \`bootstrapdata\` | Logto bootstrap results |
|
||
|
||
### Backup Commands
|
||
|
||
\`\`\`bash
|
||
# PostgreSQL
|
||
docker compose -p ${COMPOSE_PROJECT} exec postgres pg_dump -U cameleer cameleer_saas > backup.sql
|
||
|
||
# ClickHouse
|
||
docker compose -p ${COMPOSE_PROJECT} exec clickhouse clickhouse-client --query "SELECT * FROM cameleer.traces FORMAT Native" > traces.native
|
||
\`\`\`
|
||
|
||
## Upgrading
|
||
|
||
Re-run the installer with a new version:
|
||
|
||
\`\`\`bash
|
||
curl -sfL https://install.cameleer.io | bash -s -- --install-dir ${INSTALL_DIR} --version NEW_VERSION
|
||
\`\`\`
|
||
|
||
The installer preserves your \`.env\`, credentials, and data volumes. Only the compose file and images are updated.
|
||
|
||
## Troubleshooting
|
||
|
||
| Issue | Command |
|
||
|---|---|
|
||
| Service not starting | \`docker compose -p ${COMPOSE_PROJECT} logs SERVICE_NAME\` |
|
||
| Bootstrap failed | \`docker compose -p ${COMPOSE_PROJECT} logs logto\` |
|
||
| Routing issues | \`docker compose -p ${COMPOSE_PROJECT} logs traefik\` |
|
||
| Database issues | \`docker compose -p ${COMPOSE_PROJECT} exec postgres psql -U cameleer -d cameleer_saas\` |
|
||
|
||
## Uninstalling
|
||
|
||
\`\`\`bash
|
||
# Stop and remove containers
|
||
cd ${INSTALL_DIR} && docker compose -p ${COMPOSE_PROJECT} down
|
||
|
||
# Remove data volumes (DESTRUCTIVE)
|
||
cd ${INSTALL_DIR} && docker compose -p ${COMPOSE_PROJECT} down -v
|
||
|
||
# Remove install directory
|
||
rm -rf ${INSTALL_DIR}
|
||
\`\`\`
|
||
EOF
|
||
|
||
log_info "Generated INSTALL.md"
|
||
}
|
||
|
||
print_credentials() {
|
||
echo ""
|
||
echo -e "${BOLD}==========================================${NC}"
|
||
echo -e "${BOLD} CAMELEER PLATFORM CREDENTIALS${NC}"
|
||
echo -e "${BOLD}==========================================${NC}"
|
||
echo ""
|
||
echo -e " Admin Console: ${BLUE}${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/platform/${NC}"
|
||
echo -e " Admin User: ${BOLD}${ADMIN_USER}${NC}"
|
||
echo -e " Admin Password: ${BOLD}${ADMIN_PASS}${NC}"
|
||
echo ""
|
||
echo -e " PostgreSQL: cameleer / ${POSTGRES_PASSWORD}"
|
||
echo -e " ClickHouse: default / ${CLICKHOUSE_PASSWORD}"
|
||
echo ""
|
||
if [ "$VENDOR_ENABLED" = "true" ]; then
|
||
echo -e " Vendor User: ${BOLD}${VENDOR_USER}${NC}"
|
||
echo -e " Vendor Password: ${BOLD}${VENDOR_PASS}${NC}"
|
||
echo ""
|
||
fi
|
||
if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then
|
||
echo -e " Logto Console: ${BLUE}${PUBLIC_PROTOCOL}://${PUBLIC_HOST}:${LOGTO_CONSOLE_PORT}${NC}"
|
||
echo ""
|
||
fi
|
||
echo -e " Credentials saved to: ${INSTALL_DIR}/credentials.txt"
|
||
echo -e " ${YELLOW}Secure this file and delete after noting credentials.${NC}"
|
||
echo ""
|
||
}
|
||
|
||
print_summary() {
|
||
echo -e "${GREEN}==========================================${NC}"
|
||
echo -e "${GREEN} Installation complete!${NC}"
|
||
echo -e "${GREEN}==========================================${NC}"
|
||
echo ""
|
||
echo " Install directory: $INSTALL_DIR"
|
||
echo " Documentation: $INSTALL_DIR/INSTALL.md"
|
||
echo ""
|
||
echo " To manage the stack:"
|
||
echo " cd $INSTALL_DIR"
|
||
echo " docker compose -p $COMPOSE_PROJECT ps # status"
|
||
echo " docker compose -p $COMPOSE_PROJECT logs -f # logs"
|
||
echo " docker compose -p $COMPOSE_PROJECT down # stop"
|
||
echo ""
|
||
}
|
||
|
||
# --- Re-run and upgrade ---
|
||
|
||
show_rerun_menu() {
|
||
local current_version
|
||
current_version=$(grep '^version=' "$INSTALL_DIR/cameleer.conf" 2>/dev/null | cut -d= -f2 || echo "unknown")
|
||
local current_host
|
||
current_host=$(grep '^public_host=' "$INSTALL_DIR/cameleer.conf" 2>/dev/null | cut -d= -f2 || echo "unknown")
|
||
|
||
echo ""
|
||
echo -e "${BOLD}Existing Cameleer installation detected (v${current_version})${NC}"
|
||
echo " Install directory: $INSTALL_DIR"
|
||
echo " Public host: $current_host"
|
||
echo ""
|
||
|
||
if [ "$MODE" = "silent" ]; then
|
||
RERUN_ACTION="${RERUN_ACTION:-upgrade}"
|
||
return
|
||
fi
|
||
|
||
if [ -n "$RERUN_ACTION" ]; then return; fi
|
||
|
||
local new_version="${VERSION:-$CAMELEER_DEFAULT_VERSION}"
|
||
echo " [1] Upgrade to v${new_version} (pull new images, update compose)"
|
||
echo " [2] Reconfigure (re-run interactive setup, preserve data)"
|
||
echo " [3] Reinstall (fresh install, WARNING: destroys data volumes)"
|
||
echo " [4] Cancel"
|
||
echo ""
|
||
|
||
local choice
|
||
read -rp " Select [1]: " choice
|
||
case "${choice:-1}" in
|
||
1) RERUN_ACTION="upgrade" ;;
|
||
2) RERUN_ACTION="reconfigure" ;;
|
||
3) RERUN_ACTION="reinstall" ;;
|
||
4) echo "Cancelled."; exit 0 ;;
|
||
*) echo "Invalid choice."; exit 1 ;;
|
||
esac
|
||
}
|
||
|
||
handle_rerun() {
|
||
case "$RERUN_ACTION" in
|
||
upgrade)
|
||
log_info "Upgrading installation..."
|
||
load_config_file "$INSTALL_DIR/cameleer.conf"
|
||
load_env_overrides
|
||
merge_config
|
||
generate_compose_file
|
||
docker_compose_pull
|
||
docker_compose_down
|
||
docker_compose_up
|
||
verify_health
|
||
generate_install_doc
|
||
print_summary
|
||
exit 0
|
||
;;
|
||
reconfigure)
|
||
log_info "Reconfiguring installation..."
|
||
return
|
||
;;
|
||
reinstall)
|
||
if [ "${CONFIRM_DESTROY}" != "true" ]; then
|
||
echo ""
|
||
log_warn "This will destroy ALL data (databases, certificates, bootstrap)."
|
||
if ! prompt_yesno "Are you sure? This cannot be undone."; then
|
||
echo "Cancelled."
|
||
exit 0
|
||
fi
|
||
fi
|
||
log_info "Reinstalling..."
|
||
docker_compose_down 2>/dev/null || true
|
||
(cd "$INSTALL_DIR" && docker compose -p "${COMPOSE_PROJECT:-cameleer-saas}" down -v 2>/dev/null || true)
|
||
rm -f "$INSTALL_DIR/.env" "$INSTALL_DIR/docker-compose.yml" \
|
||
"$INSTALL_DIR/cameleer.conf" "$INSTALL_DIR/credentials.txt" \
|
||
"$INSTALL_DIR/INSTALL.md" "$INSTALL_DIR/.env.bak"
|
||
rm -rf "$INSTALL_DIR/certs"
|
||
IS_RERUN=false
|
||
return
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# --- Main ---
|
||
|
||
main() {
|
||
parse_args "$@"
|
||
|
||
print_banner
|
||
|
||
# Load config sources (CLI already loaded via parse_args)
|
||
if [ -n "$CONFIG_FILE_PATH" ]; then
|
||
load_config_file "$CONFIG_FILE_PATH"
|
||
fi
|
||
load_env_overrides
|
||
|
||
# Check for existing installation
|
||
detect_existing_install
|
||
if [ "$IS_RERUN" = true ]; then
|
||
show_rerun_menu
|
||
handle_rerun
|
||
fi
|
||
|
||
# Prerequisites
|
||
check_prerequisites
|
||
|
||
# Auto-detect defaults
|
||
auto_detect
|
||
|
||
# Interactive prompts (unless silent)
|
||
if [ "$MODE" != "silent" ]; then
|
||
select_mode
|
||
if [ "$MODE" = "expert" ]; then
|
||
run_expert_prompts
|
||
else
|
||
run_simple_prompts
|
||
fi
|
||
fi
|
||
|
||
# Merge remaining defaults and validate
|
||
merge_config
|
||
validate_config
|
||
|
||
# Generate passwords for any empty values
|
||
generate_passwords
|
||
|
||
# Create install directory
|
||
mkdir -p "$INSTALL_DIR"
|
||
|
||
# Copy custom certs if provided
|
||
if [ "$TLS_MODE" = "custom" ]; then
|
||
copy_certs
|
||
fi
|
||
|
||
# Generate configuration files
|
||
generate_env_file
|
||
generate_compose_file
|
||
write_config_file
|
||
|
||
# Pull and start
|
||
docker_compose_pull
|
||
docker_compose_up
|
||
|
||
# Verify health
|
||
verify_health
|
||
|
||
# Generate output files
|
||
generate_credentials_file
|
||
generate_install_doc
|
||
|
||
# Print results
|
||
print_credentials
|
||
print_summary
|
||
}
|
||
|
||
main "$@"
|