Files
cameleer-saas/installer/install.sh
hsiegeln b70d95cbb9
Some checks failed
CI / build (push) Failing after 38s
CI / docker (push) Has been skipped
fix: pass database credentials to per-tenant servers via config
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>
2026-04-13 18:44:32 +02:00

1433 lines
43 KiB
Bash
Raw Blame History

#!/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 "$@"