#Requires -Version 5.1 <# .SYNOPSIS Cameleer SaaS Platform Installer for Windows .DESCRIPTION PowerShell port of install.sh. Produces identical output files (.env, docker-compose.yml, credentials.txt, INSTALL.md, cameleer.conf). Supports simple/expert/silent modes with the same config precedence: CLI > env vars > config file > defaults. .EXAMPLE .\install.ps1 .EXAMPLE .\install.ps1 -Silent -PublicHost myserver.example.com -InstallDir C:\cameleer .EXAMPLE .\install.ps1 -Expert -TlsMode custom -CertFile .\cert.pem -KeyFile .\key.pem #> [CmdletBinding()] param( [switch]$Silent, [switch]$Expert, [string]$Config, [string]$InstallDir, [string]$PublicHost, [string]$PublicProtocol, [string]$AdminUser, [string]$AdminPassword, [ValidateSet('self-signed', 'custom')] [string]$TlsMode, [string]$CertFile, [string]$KeyFile, [string]$CaFile, [string]$PostgresPassword, [string]$ClickhousePassword, [int]$HttpPort, [int]$HttpsPort, [int]$LogtoConsolePort, [string]$LogtoConsoleExposed, [string]$VendorEnabled, [string]$VendorUser, [string]$VendorPassword, [string]$MonitoringNetwork, [string]$Version, [string]$ComposeProject, [string]$DockerSocket, [string]$NodeTlsReject, [switch]$Reconfigure, [switch]$Reinstall, [switch]$ConfirmDestroy, [switch]$Help ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # --------------------------------------------------------------------------- # Constants and defaults # --------------------------------------------------------------------------- $CAMELEER_INSTALLER_VERSION = '1.0.0' $CAMELEER_DEFAULT_VERSION = 'latest' $REGISTRY = 'gitea.siegeln.net/cameleer' $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 = '//./pipe/docker_engine' # --------------------------------------------------------------------------- # Save environment values BEFORE CLI params override (same pattern as bash) # --------------------------------------------------------------------------- $_ENV_PUBLIC_HOST = if ($env:PUBLIC_HOST) { $env:PUBLIC_HOST } else { '' } $_ENV_PUBLIC_PROTOCOL = if ($env:PUBLIC_PROTOCOL) { $env:PUBLIC_PROTOCOL } else { '' } $_ENV_POSTGRES_PASSWORD = if ($env:POSTGRES_PASSWORD) { $env:POSTGRES_PASSWORD } else { '' } $_ENV_CLICKHOUSE_PASSWORD = if ($env:CLICKHOUSE_PASSWORD) { $env:CLICKHOUSE_PASSWORD } else { '' } $_ENV_TLS_MODE = if ($env:TLS_MODE) { $env:TLS_MODE } else { '' } $_ENV_CERT_FILE = if ($env:CERT_FILE) { $env:CERT_FILE } else { '' } $_ENV_KEY_FILE = if ($env:KEY_FILE) { $env:KEY_FILE } else { '' } $_ENV_CA_FILE = if ($env:CA_FILE) { $env:CA_FILE } else { '' } $_ENV_HTTP_PORT = if ($env:HTTP_PORT) { $env:HTTP_PORT } else { '' } $_ENV_HTTPS_PORT = if ($env:HTTPS_PORT) { $env:HTTPS_PORT } else { '' } $_ENV_LOGTO_CONSOLE_PORT = if ($env:LOGTO_CONSOLE_PORT) { $env:LOGTO_CONSOLE_PORT } else { '' } $_ENV_LOGTO_CONSOLE_EXPOSED = if ($env:LOGTO_CONSOLE_EXPOSED) { $env:LOGTO_CONSOLE_EXPOSED } else { '' } $_ENV_VENDOR_ENABLED = if ($env:VENDOR_ENABLED) { $env:VENDOR_ENABLED } else { '' } $_ENV_VENDOR_USER = if ($env:VENDOR_USER) { $env:VENDOR_USER } else { '' } $_ENV_VENDOR_PASS = if ($env:VENDOR_PASS) { $env:VENDOR_PASS } else { '' } $_ENV_MONITORING_NETWORK = if ($env:MONITORING_NETWORK) { $env:MONITORING_NETWORK } else { '' } $_ENV_COMPOSE_PROJECT = if ($env:COMPOSE_PROJECT) { $env:COMPOSE_PROJECT } else { '' } $_ENV_DOCKER_SOCKET = if ($env:DOCKER_SOCKET) { $env:DOCKER_SOCKET } else { '' } $_ENV_NODE_TLS_REJECT = if ($env:NODE_TLS_REJECT) { $env:NODE_TLS_REJECT } else { '' } # --------------------------------------------------------------------------- # Mutable config state (populated from CLI, env, config file, prompts) # --------------------------------------------------------------------------- $script:CFG_INSTALL_DIR = if ($InstallDir) { $InstallDir } else { '' } $script:CFG_PUBLIC_HOST = if ($PublicHost) { $PublicHost } else { '' } $script:CFG_PUBLIC_PROTOCOL = if ($PublicProtocol) { $PublicProtocol } else { '' } $script:CFG_ADMIN_USER = if ($AdminUser) { $AdminUser } else { '' } $script:CFG_ADMIN_PASS = if ($AdminPassword) { $AdminPassword } else { '' } $script:CFG_TLS_MODE = if ($TlsMode) { $TlsMode } else { '' } $script:CFG_CERT_FILE = if ($CertFile) { $CertFile } else { '' } $script:CFG_KEY_FILE = if ($KeyFile) { $KeyFile } else { '' } $script:CFG_CA_FILE = if ($CaFile) { $CaFile } else { '' } $script:CFG_POSTGRES_PASSWORD = if ($PostgresPassword) { $PostgresPassword } else { '' } $script:CFG_CLICKHOUSE_PASSWORD = if ($ClickhousePassword) { $ClickhousePassword } else { '' } $script:CFG_HTTP_PORT = if ($HttpPort -ne 0) { "$HttpPort" } else { '' } $script:CFG_HTTPS_PORT = if ($HttpsPort -ne 0) { "$HttpsPort" } else { '' } $script:CFG_LOGTO_CONSOLE_PORT = if ($LogtoConsolePort -ne 0) { "$LogtoConsolePort" } else { '' } $script:CFG_LOGTO_CONSOLE_EXPOSED = if ($LogtoConsoleExposed) { $LogtoConsoleExposed } else { '' } $script:CFG_VENDOR_ENABLED = if ($VendorEnabled) { $VendorEnabled } else { '' } $script:CFG_VENDOR_USER = if ($VendorUser) { $VendorUser } else { '' } $script:CFG_VENDOR_PASS = if ($VendorPassword) { $VendorPassword } else { '' } $script:CFG_MONITORING_NETWORK = if ($MonitoringNetwork) { $MonitoringNetwork } else { '' } $script:CFG_VERSION = if ($Version) { $Version } else { '' } $script:CFG_COMPOSE_PROJECT = if ($ComposeProject) { $ComposeProject } else { '' } $script:CFG_DOCKER_SOCKET = if ($DockerSocket) { $DockerSocket } else { '' } $script:CFG_NODE_TLS_REJECT = if ($NodeTlsReject) { $NodeTlsReject } else { '' } # State $script:MODE = '' # simple, expert, silent $script:IS_RERUN = $false $script:RERUN_ACTION = '' # upgrade, reconfigure, reinstall $script:CONFIRM_DESTROY = $false # Translate switch params to state if ($Silent) { $script:MODE = 'silent' } if ($Expert) { $script:MODE = 'expert' } if ($Reconfigure) { $script:RERUN_ACTION = 'reconfigure' } if ($Reinstall) { $script:RERUN_ACTION = 'reinstall' } if ($ConfirmDestroy){ $script:CONFIRM_DESTROY = $true } # --------------------------------------------------------------------------- # Utility functions # --------------------------------------------------------------------------- function Write-Info($msg) { Write-Host "[INFO] $msg" -ForegroundColor Green } function Write-Warn($msg) { Write-Host "[WARN] $msg" -ForegroundColor Yellow } function Write-Err($msg) { Write-Host "[ERROR] $msg" -ForegroundColor Red } function Write-Success($msg) { Write-Host "[OK] $msg" -ForegroundColor Green } function Show-Banner { Write-Host '' Write-Host ' ____ _ ' -ForegroundColor White Write-Host ' / ___|__ _ _ __ ___ ___ | | ___ ___ _ __ ' -ForegroundColor White Write-Host '| | / _` | `''_ ` _ \ / _ \| |/ _ \/ _ \ `''__|' -ForegroundColor White Write-Host '| |__| (_| | | | | | | __/| | __/ __/ | ' -ForegroundColor White Write-Host ' \____\__,_|_| |_| |_|\___||_|\___|\___||_| ' -ForegroundColor White Write-Host '' Write-Host " SaaS Platform Installer v$CAMELEER_INSTALLER_VERSION" -ForegroundColor White Write-Host '' } function Show-Help { Write-Host 'Usage: install.ps1 [OPTIONS]' Write-Host '' Write-Host 'Modes:' Write-Host ' (default) Interactive simple mode (6 questions)' Write-Host ' -Expert Interactive expert mode (all options)' Write-Host ' -Silent Non-interactive, use defaults + overrides' Write-Host '' Write-Host 'Options:' Write-Host ' -InstallDir DIR Install directory (default: ./cameleer)' Write-Host ' -PublicHost HOST Public hostname (default: auto-detect)' Write-Host ' -AdminUser USER Admin username (default: admin)' Write-Host ' -AdminPassword PASS Admin password (default: generated)' Write-Host ' -TlsMode MODE self-signed or custom (default: self-signed)' Write-Host ' -CertFile PATH TLS certificate file' Write-Host ' -KeyFile PATH TLS key file' Write-Host ' -CaFile PATH CA bundle file' Write-Host ' -MonitoringNetwork NAME Docker network for Prometheus scraping' Write-Host ' -Version TAG Image version tag (default: latest)' Write-Host ' -Config FILE Load config from file' Write-Host ' -Help Show this help' Write-Host '' Write-Host 'Expert options:' Write-Host ' -PostgresPassword, -ClickhousePassword, -HttpPort,' Write-Host ' -HttpsPort, -LogtoConsolePort, -LogtoConsoleExposed,' Write-Host ' -VendorEnabled, -VendorUser, -VendorPassword,' Write-Host ' -ComposeProject, -DockerSocket, -NodeTlsReject' Write-Host '' Write-Host 'Re-run options:' Write-Host ' -Reconfigure Re-run interactive setup (preserve data)' Write-Host ' -Reinstall -ConfirmDestroy Fresh install (destroys data)' Write-Host '' Write-Host 'Config precedence: CLI flags > env vars > config file > defaults' } function New-SecurePassword { <# .SYNOPSIS Generates a random password equivalent to: openssl rand -base64 24 | tr -d '/+=' | head -c 32 #> $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() $bytes = New-Object byte[] 24 $rng.GetBytes($bytes) $rng.Dispose() $b64 = [Convert]::ToBase64String($bytes) # Strip /+= characters like bash tr -d '/+=' $clean = $b64 -replace '[/+=]', '' # Take up to 32 characters like head -c 32 if ($clean.Length -gt 32) { $clean = $clean.Substring(0, 32) } return $clean } function Invoke-Prompt { param( [string]$PromptText, [string]$Default = '' ) if ($Default -ne '') { $input = Read-Host " $PromptText [$Default]" if ([string]::IsNullOrEmpty($input)) { return $Default } return $input } else { $input = Read-Host " $PromptText" return $input } } function Invoke-PasswordPrompt { param( [string]$PromptText, [string]$Default = '' ) if ($Default -ne '') { $ss = Read-Host " $PromptText [********]" -AsSecureString $plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto( [Runtime.InteropServices.Marshal]::SecureStringToBSTR($ss)) if ([string]::IsNullOrEmpty($plain)) { return $Default } return $plain } else { $ss = Read-Host " $PromptText" -AsSecureString $plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto( [Runtime.InteropServices.Marshal]::SecureStringToBSTR($ss)) return $plain } } function Invoke-YesNoPrompt { param( [string]$PromptText, [string]$Default = 'n' ) if ($Default -eq 'y') { $input = Read-Host " $PromptText [Y/n]" if ([string]::IsNullOrEmpty($input)) { return $true } return ($input -match '^[yY]') } else { $input = Read-Host " $PromptText [y/N]" if ([string]::IsNullOrEmpty($input)) { return $false } return ($input -match '^[yY]') } } # --------------------------------------------------------------------------- # Config file loading # --------------------------------------------------------------------------- function Load-ConfigFile { param([string]$FilePath) if (-not (Test-Path $FilePath -PathType Leaf)) { return } foreach ($line in [System.IO.File]::ReadAllLines($FilePath)) { # Skip comments and empty lines if ($line -match '^\s*#' -or $line -match '^\s*$') { continue } if ($line -notmatch '=') { continue } $idx = $line.IndexOf('=') $key = $line.Substring(0, $idx).Trim() $value = $line.Substring($idx + 1).Trim() switch ($key) { 'install_dir' { if ($script:CFG_INSTALL_DIR -eq '') { $script:CFG_INSTALL_DIR = $value } } 'public_host' { if ($script:CFG_PUBLIC_HOST -eq '') { $script:CFG_PUBLIC_HOST = $value } } 'public_protocol' { if ($script:CFG_PUBLIC_PROTOCOL -eq '') { $script:CFG_PUBLIC_PROTOCOL = $value } } 'admin_user' { if ($script:CFG_ADMIN_USER -eq '') { $script:CFG_ADMIN_USER = $value } } 'admin_password' { if ($script:CFG_ADMIN_PASS -eq '') { $script:CFG_ADMIN_PASS = $value } } 'tls_mode' { if ($script:CFG_TLS_MODE -eq '') { $script:CFG_TLS_MODE = $value } } 'cert_file' { if ($script:CFG_CERT_FILE -eq '') { $script:CFG_CERT_FILE = $value } } 'key_file' { if ($script:CFG_KEY_FILE -eq '') { $script:CFG_KEY_FILE = $value } } 'ca_file' { if ($script:CFG_CA_FILE -eq '') { $script:CFG_CA_FILE = $value } } 'postgres_password' { if ($script:CFG_POSTGRES_PASSWORD -eq '') { $script:CFG_POSTGRES_PASSWORD = $value } } 'clickhouse_password' { if ($script:CFG_CLICKHOUSE_PASSWORD -eq '') { $script:CFG_CLICKHOUSE_PASSWORD = $value } } 'http_port' { if ($script:CFG_HTTP_PORT -eq '') { $script:CFG_HTTP_PORT = $value } } 'https_port' { if ($script:CFG_HTTPS_PORT -eq '') { $script:CFG_HTTPS_PORT = $value } } 'logto_console_port' { if ($script:CFG_LOGTO_CONSOLE_PORT -eq '') { $script:CFG_LOGTO_CONSOLE_PORT = $value } } 'logto_console_exposed' { if ($script:CFG_LOGTO_CONSOLE_EXPOSED -eq '') { $script:CFG_LOGTO_CONSOLE_EXPOSED = $value } } 'vendor_enabled' { if ($script:CFG_VENDOR_ENABLED -eq '') { $script:CFG_VENDOR_ENABLED = $value } } 'vendor_user' { if ($script:CFG_VENDOR_USER -eq '') { $script:CFG_VENDOR_USER = $value } } 'vendor_password' { if ($script:CFG_VENDOR_PASS -eq '') { $script:CFG_VENDOR_PASS = $value } } 'monitoring_network' { if ($script:CFG_MONITORING_NETWORK -eq '') { $script:CFG_MONITORING_NETWORK = $value } } 'version' { if ($script:CFG_VERSION -eq '') { $script:CFG_VERSION = $value } } 'compose_project' { if ($script:CFG_COMPOSE_PROJECT -eq '') { $script:CFG_COMPOSE_PROJECT = $value } } 'docker_socket' { if ($script:CFG_DOCKER_SOCKET -eq '') { $script:CFG_DOCKER_SOCKET = $value } } 'node_tls_reject' { if ($script:CFG_NODE_TLS_REJECT -eq '') { $script:CFG_NODE_TLS_REJECT = $value } } } } } # --------------------------------------------------------------------------- # Environment variable overrides (fills gaps left by CLI) # --------------------------------------------------------------------------- function Load-EnvOverrides { if ($script:CFG_INSTALL_DIR -eq '') { $script:CFG_INSTALL_DIR = if ($env:CAMELEER_INSTALL_DIR) { $env:CAMELEER_INSTALL_DIR } else { '' } } if ($script:CFG_PUBLIC_HOST -eq '') { $script:CFG_PUBLIC_HOST = $_ENV_PUBLIC_HOST } if ($script:CFG_PUBLIC_PROTOCOL -eq '') { $script:CFG_PUBLIC_PROTOCOL = $_ENV_PUBLIC_PROTOCOL } if ($script:CFG_ADMIN_USER -eq '') { $script:CFG_ADMIN_USER = if ($env:SAAS_ADMIN_USER) { $env:SAAS_ADMIN_USER } else { '' } } if ($script:CFG_ADMIN_PASS -eq '') { $script:CFG_ADMIN_PASS = if ($env:SAAS_ADMIN_PASS) { $env:SAAS_ADMIN_PASS } else { '' } } if ($script:CFG_TLS_MODE -eq '') { $script:CFG_TLS_MODE = $_ENV_TLS_MODE } if ($script:CFG_CERT_FILE -eq '') { $script:CFG_CERT_FILE = $_ENV_CERT_FILE } if ($script:CFG_KEY_FILE -eq '') { $script:CFG_KEY_FILE = $_ENV_KEY_FILE } if ($script:CFG_CA_FILE -eq '') { $script:CFG_CA_FILE = $_ENV_CA_FILE } if ($script:CFG_POSTGRES_PASSWORD -eq '') { $script:CFG_POSTGRES_PASSWORD = $_ENV_POSTGRES_PASSWORD } if ($script:CFG_CLICKHOUSE_PASSWORD -eq '') { $script:CFG_CLICKHOUSE_PASSWORD = $_ENV_CLICKHOUSE_PASSWORD } if ($script:CFG_HTTP_PORT -eq '') { $script:CFG_HTTP_PORT = $_ENV_HTTP_PORT } if ($script:CFG_HTTPS_PORT -eq '') { $script:CFG_HTTPS_PORT = $_ENV_HTTPS_PORT } if ($script:CFG_LOGTO_CONSOLE_PORT -eq '') { $script:CFG_LOGTO_CONSOLE_PORT = $_ENV_LOGTO_CONSOLE_PORT } if ($script:CFG_LOGTO_CONSOLE_EXPOSED -eq '') { $script:CFG_LOGTO_CONSOLE_EXPOSED = $_ENV_LOGTO_CONSOLE_EXPOSED } if ($script:CFG_VENDOR_ENABLED -eq '') { $script:CFG_VENDOR_ENABLED = $_ENV_VENDOR_ENABLED } if ($script:CFG_VENDOR_USER -eq '') { $script:CFG_VENDOR_USER = $_ENV_VENDOR_USER } if ($script:CFG_VENDOR_PASS -eq '') { $script:CFG_VENDOR_PASS = $_ENV_VENDOR_PASS } if ($script:CFG_MONITORING_NETWORK -eq '') { $script:CFG_MONITORING_NETWORK = $_ENV_MONITORING_NETWORK } if ($script:CFG_VERSION -eq '') { $script:CFG_VERSION = if ($env:CAMELEER_VERSION) { $env:CAMELEER_VERSION } else { '' } } if ($script:CFG_COMPOSE_PROJECT -eq '') { $script:CFG_COMPOSE_PROJECT = $_ENV_COMPOSE_PROJECT } if ($script:CFG_DOCKER_SOCKET -eq '') { $script:CFG_DOCKER_SOCKET = $_ENV_DOCKER_SOCKET } if ($script:CFG_NODE_TLS_REJECT -eq '') { $script:CFG_NODE_TLS_REJECT = $_ENV_NODE_TLS_REJECT } } # --------------------------------------------------------------------------- # Prerequisites # --------------------------------------------------------------------------- function Test-PortAvailable { param([string]$Port, [string]$Name) try { $portNum = [int]$Port $result = Test-NetConnection -ComputerName 127.0.0.1 -Port $portNum -WarningAction SilentlyContinue -ErrorAction SilentlyContinue if ($result.TcpTestSucceeded) { Write-Warn "Port $Port ($Name) is already in use." } } catch { # Ignore errors — port check is advisory only } } function Test-Prerequisites { Write-Info 'Checking prerequisites...' $errors = 0 # Docker try { $dockerVer = & docker version --format '{{.Server.Version}}' 2>$null if ($LASTEXITCODE -ne 0) { throw 'docker version failed' } Write-Info "Docker version: $dockerVer" } catch { Write-Err 'Docker is not installed or not running.' Write-Host ' Install Docker Desktop: https://docs.docker.com/desktop/install/windows-install/' $errors++ } # Docker Compose v2 try { $null = & docker compose version 2>$null if ($LASTEXITCODE -ne 0) { throw 'compose not available' } $composeVer = & docker compose version --short 2>$null Write-Info "Docker Compose version: $composeVer" } catch { Write-Err "Docker Compose v2 is not available. 'docker compose' subcommand required." $errors++ } # Docker socket / pipe $socket = if ($script:CFG_DOCKER_SOCKET -ne '') { $script:CFG_DOCKER_SOCKET } else { $DEFAULT_DOCKER_SOCKET } if ($socket -like '//./pipe/*') { $pipeName = $socket -replace '^//\./pipe/', '' if (-not (Test-Path "\\.\pipe\$pipeName")) { Write-Warn "Docker named pipe not found at $socket" } } elseif (-not (Test-Path $socket)) { Write-Warn "Docker socket not found at $socket" } # Port checks $httpPort = if ($script:CFG_HTTP_PORT -ne '') { $script:CFG_HTTP_PORT } else { $DEFAULT_HTTP_PORT } $httpsPort = if ($script:CFG_HTTPS_PORT -ne '') { $script:CFG_HTTPS_PORT } else { $DEFAULT_HTTPS_PORT } $logtoPort = if ($script:CFG_LOGTO_CONSOLE_PORT -ne '') { $script:CFG_LOGTO_CONSOLE_PORT } else { $DEFAULT_LOGTO_CONSOLE_PORT } Test-PortAvailable $httpPort 'HTTP' Test-PortAvailable $httpsPort 'HTTPS' Test-PortAvailable $logtoPort 'Logto Console' if ($errors -gt 0) { Write-Err "$errors prerequisite(s) not met. Please install missing dependencies and retry." exit 1 } Write-Success 'All prerequisites met.' } # --------------------------------------------------------------------------- # Auto-detection # --------------------------------------------------------------------------- function Get-AutoDetectedDefaults { if ($script:CFG_PUBLIC_HOST -eq '') { try { $entry = [System.Net.Dns]::GetHostEntry([System.Net.Dns]::GetHostName()) $script:CFG_PUBLIC_HOST = $entry.HostName } catch { $script:CFG_PUBLIC_HOST = [System.Net.Dns]::GetHostName() } if (-not $script:CFG_PUBLIC_HOST) { $script:CFG_PUBLIC_HOST = 'localhost' } } if ($script:CFG_DOCKER_SOCKET -eq '') { $script:CFG_DOCKER_SOCKET = $DEFAULT_DOCKER_SOCKET } } # --------------------------------------------------------------------------- # Detect existing installation # --------------------------------------------------------------------------- function Find-ExistingInstall { $dir = if ($script:CFG_INSTALL_DIR -ne '') { $script:CFG_INSTALL_DIR } else { $DEFAULT_INSTALL_DIR } $confPath = Join-Path $dir 'cameleer.conf' if (Test-Path $confPath -PathType Leaf) { $script:IS_RERUN = $true $script:CFG_INSTALL_DIR = $dir Load-ConfigFile $confPath } } # --------------------------------------------------------------------------- # Mode selection # --------------------------------------------------------------------------- function Select-InstallMode { if ($script:MODE -ne '') { return } Write-Host '' Write-Host ' Installation mode:' Write-Host ' [1] Simple — 6 questions, sensible defaults (recommended)' Write-Host ' [2] Expert — configure everything' Write-Host '' $choice = Read-Host ' Select mode [1]' if ($choice -eq '2') { $script:MODE = 'expert' } else { $script:MODE = 'simple' } } # --------------------------------------------------------------------------- # Interactive prompts # --------------------------------------------------------------------------- function Invoke-SimplePrompts { Write-Host '' Write-Host '--- Simple Installation ---' -ForegroundColor White Write-Host '' $script:CFG_INSTALL_DIR = Invoke-Prompt 'Install directory' (if ($script:CFG_INSTALL_DIR -ne '') { $script:CFG_INSTALL_DIR } else { $DEFAULT_INSTALL_DIR }) $script:CFG_PUBLIC_HOST = Invoke-Prompt 'Public hostname' (if ($script:CFG_PUBLIC_HOST -ne '') { $script:CFG_PUBLIC_HOST } else { 'localhost' }) $script:CFG_ADMIN_USER = Invoke-Prompt 'Admin username' (if ($script:CFG_ADMIN_USER -ne '') { $script:CFG_ADMIN_USER } else { $DEFAULT_ADMIN_USER }) if (Invoke-YesNoPrompt 'Auto-generate admin password?' 'y') { $script:CFG_ADMIN_PASS = '' } else { $script:CFG_ADMIN_PASS = Invoke-PasswordPrompt 'Admin password' } Write-Host '' if (Invoke-YesNoPrompt 'Use custom TLS certificates? (no = self-signed)') { $script:CFG_TLS_MODE = 'custom' $script:CFG_CERT_FILE = Invoke-Prompt 'Path to certificate file (PEM)' $script:CFG_KEY_FILE = Invoke-Prompt 'Path to private key file (PEM)' if (Invoke-YesNoPrompt 'Include CA bundle?') { $script:CFG_CA_FILE = Invoke-Prompt 'Path to CA bundle (PEM)' } } else { $script:CFG_TLS_MODE = 'self-signed' } Write-Host '' $script:CFG_MONITORING_NETWORK = Invoke-Prompt 'Monitoring network name (empty = skip)' } function Invoke-ExpertPrompts { Write-Host '' Write-Host '--- Expert Installation ---' -ForegroundColor White Invoke-SimplePrompts Write-Host '' Write-Host ' Credentials:' -ForegroundColor White if (Invoke-YesNoPrompt 'Auto-generate database passwords?' 'y') { $script:CFG_POSTGRES_PASSWORD = '' $script:CFG_CLICKHOUSE_PASSWORD = '' } else { $script:CFG_POSTGRES_PASSWORD = Invoke-PasswordPrompt 'PostgreSQL password' $script:CFG_CLICKHOUSE_PASSWORD = Invoke-PasswordPrompt 'ClickHouse password' } Write-Host '' if (Invoke-YesNoPrompt 'Enable vendor account?') { $script:CFG_VENDOR_ENABLED = 'true' $script:CFG_VENDOR_USER = Invoke-Prompt 'Vendor username' (if ($script:CFG_VENDOR_USER -ne '') { $script:CFG_VENDOR_USER } else { $DEFAULT_VENDOR_USER }) if (Invoke-YesNoPrompt 'Auto-generate vendor password?' 'y') { $script:CFG_VENDOR_PASS = '' } else { $script:CFG_VENDOR_PASS = Invoke-PasswordPrompt 'Vendor password' } } else { $script:CFG_VENDOR_ENABLED = 'false' } Write-Host '' Write-Host ' Networking:' -ForegroundColor White $script:CFG_HTTP_PORT = Invoke-Prompt 'HTTP port' (if ($script:CFG_HTTP_PORT -ne '') { $script:CFG_HTTP_PORT } else { $DEFAULT_HTTP_PORT }) $script:CFG_HTTPS_PORT = Invoke-Prompt 'HTTPS port' (if ($script:CFG_HTTPS_PORT -ne '') { $script:CFG_HTTPS_PORT } else { $DEFAULT_HTTPS_PORT }) $script:CFG_LOGTO_CONSOLE_PORT = Invoke-Prompt 'Logto admin console port' (if ($script:CFG_LOGTO_CONSOLE_PORT -ne '') { $script:CFG_LOGTO_CONSOLE_PORT } else { $DEFAULT_LOGTO_CONSOLE_PORT }) Write-Host '' Write-Host ' Docker:' -ForegroundColor White $script:CFG_VERSION = Invoke-Prompt 'Image version/tag' (if ($script:CFG_VERSION -ne '') { $script:CFG_VERSION } else { $CAMELEER_DEFAULT_VERSION }) $script:CFG_COMPOSE_PROJECT = Invoke-Prompt 'Compose project name' (if ($script:CFG_COMPOSE_PROJECT -ne '') { $script:CFG_COMPOSE_PROJECT } else { $DEFAULT_COMPOSE_PROJECT }) $script:CFG_DOCKER_SOCKET = Invoke-Prompt 'Docker socket path' (if ($script:CFG_DOCKER_SOCKET -ne '') { $script:CFG_DOCKER_SOCKET } else { $DEFAULT_DOCKER_SOCKET }) Write-Host '' Write-Host ' Logto:' -ForegroundColor White if (Invoke-YesNoPrompt 'Expose Logto admin console externally?' 'y') { $script:CFG_LOGTO_CONSOLE_EXPOSED = 'true' } else { $script:CFG_LOGTO_CONSOLE_EXPOSED = 'false' } } # --------------------------------------------------------------------------- # Config merge and validation # --------------------------------------------------------------------------- function Merge-Config { if ($script:CFG_INSTALL_DIR -eq '') { $script:CFG_INSTALL_DIR = $DEFAULT_INSTALL_DIR } if ($script:CFG_PUBLIC_HOST -eq '') { $script:CFG_PUBLIC_HOST = 'localhost' } if ($script:CFG_PUBLIC_PROTOCOL -eq '') { $script:CFG_PUBLIC_PROTOCOL = $DEFAULT_PUBLIC_PROTOCOL } if ($script:CFG_ADMIN_USER -eq '') { $script:CFG_ADMIN_USER = $DEFAULT_ADMIN_USER } if ($script:CFG_TLS_MODE -eq '') { $script:CFG_TLS_MODE = $DEFAULT_TLS_MODE } if ($script:CFG_HTTP_PORT -eq '') { $script:CFG_HTTP_PORT = $DEFAULT_HTTP_PORT } if ($script:CFG_HTTPS_PORT -eq '') { $script:CFG_HTTPS_PORT = $DEFAULT_HTTPS_PORT } if ($script:CFG_LOGTO_CONSOLE_PORT -eq '') { $script:CFG_LOGTO_CONSOLE_PORT = $DEFAULT_LOGTO_CONSOLE_PORT } if ($script:CFG_LOGTO_CONSOLE_EXPOSED -eq '') { $script:CFG_LOGTO_CONSOLE_EXPOSED = $DEFAULT_LOGTO_CONSOLE_EXPOSED } if ($script:CFG_VENDOR_ENABLED -eq '') { $script:CFG_VENDOR_ENABLED = $DEFAULT_VENDOR_ENABLED } if ($script:CFG_VENDOR_USER -eq '') { $script:CFG_VENDOR_USER = $DEFAULT_VENDOR_USER } if ($script:CFG_VERSION -eq '') { $script:CFG_VERSION = $CAMELEER_DEFAULT_VERSION } if ($script:CFG_COMPOSE_PROJECT -eq '') { $script:CFG_COMPOSE_PROJECT = $DEFAULT_COMPOSE_PROJECT } if ($script:CFG_DOCKER_SOCKET -eq '') { $script:CFG_DOCKER_SOCKET = $DEFAULT_DOCKER_SOCKET } if ($script:CFG_NODE_TLS_REJECT -eq '') { if ($script:CFG_TLS_MODE -eq 'custom') { $script:CFG_NODE_TLS_REJECT = '1' } else { $script:CFG_NODE_TLS_REJECT = '0' } } } function Test-Config { $errors = 0 if ($script:CFG_TLS_MODE -eq 'custom') { if (-not (Test-Path $script:CFG_CERT_FILE -PathType Leaf)) { Write-Err "Certificate file not found: $($script:CFG_CERT_FILE)" $errors++ } if (-not (Test-Path $script:CFG_KEY_FILE -PathType Leaf)) { Write-Err "Key file not found: $($script:CFG_KEY_FILE)" $errors++ } if ($script:CFG_CA_FILE -ne '' -and -not (Test-Path $script:CFG_CA_FILE -PathType Leaf)) { Write-Err "CA bundle not found: $($script:CFG_CA_FILE)" $errors++ } } foreach ($portEntry in @( @{ Name = 'HttpPort'; Value = $script:CFG_HTTP_PORT } @{ Name = 'HttpsPort'; Value = $script:CFG_HTTPS_PORT } @{ Name = 'LogtoConsolePort'; Value = $script:CFG_LOGTO_CONSOLE_PORT } )) { $portVal = $portEntry.Value if ($portVal -notmatch '^\d+$' -or [int]$portVal -lt 1 -or [int]$portVal -gt 65535) { Write-Err "Invalid port for $($portEntry.Name): $portVal" $errors++ } } if ($errors -gt 0) { Write-Err 'Configuration validation failed.' exit 1 } Write-Success 'Configuration validated.' } # --------------------------------------------------------------------------- # Password generation # --------------------------------------------------------------------------- function New-Passwords { if ($script:CFG_ADMIN_PASS -eq '') { $script:CFG_ADMIN_PASS = New-SecurePassword Write-Info 'Generated admin password.' } if ($script:CFG_POSTGRES_PASSWORD -eq '') { $script:CFG_POSTGRES_PASSWORD = New-SecurePassword Write-Info 'Generated PostgreSQL password.' } if ($script:CFG_CLICKHOUSE_PASSWORD -eq '') { $script:CFG_CLICKHOUSE_PASSWORD = New-SecurePassword Write-Info 'Generated ClickHouse password.' } if ($script:CFG_VENDOR_ENABLED -eq 'true' -and $script:CFG_VENDOR_PASS -eq '') { $script:CFG_VENDOR_PASS = New-SecurePassword Write-Info 'Generated vendor password.' } } # --------------------------------------------------------------------------- # Certificate copy # --------------------------------------------------------------------------- function Copy-Certificates { $certsDir = Join-Path $script:CFG_INSTALL_DIR 'certs' if (-not (Test-Path $certsDir)) { New-Item -ItemType Directory -Path $certsDir -Force | Out-Null } Copy-Item $script:CFG_CERT_FILE (Join-Path $certsDir 'cert.pem') -Force Copy-Item $script:CFG_KEY_FILE (Join-Path $certsDir 'key.pem') -Force if ($script:CFG_CA_FILE -ne '') { Copy-Item $script:CFG_CA_FILE (Join-Path $certsDir 'ca.pem') -Force } Write-Info "Copied TLS certificates to $certsDir/" } # --------------------------------------------------------------------------- # Helper: write file content with LF line endings # --------------------------------------------------------------------------- function Write-LFFile { param([string]$Path, [string]$Content) # Normalize to LF only (replace CRLF then stray CR) $normalized = $Content -replace "`r`n", "`n" -replace "`r", "`n" [System.IO.File]::WriteAllText($Path, $normalized, [System.Text.Encoding]::UTF8) } function Append-LFFile { param([string]$Path, [string]$Content) $normalized = $Content -replace "`r`n", "`n" -replace "`r", "`n" $stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write) $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized) $stream.Write($bytes, 0, $bytes.Length) $stream.Close() } # --------------------------------------------------------------------------- # .env file generation # --------------------------------------------------------------------------- function New-EnvFile { $f = Join-Path $script:CFG_INSTALL_DIR '.env' $utcNow = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC') $ver = $script:CFG_VERSION $host_ = $script:CFG_PUBLIC_HOST $proto = $script:CFG_PUBLIC_PROTOCOL $httpPort = $script:CFG_HTTP_PORT $httpsPort= $script:CFG_HTTPS_PORT $logtoPort= $script:CFG_LOGTO_CONSOLE_PORT $pgPass = $script:CFG_POSTGRES_PASSWORD $chPass = $script:CFG_CLICKHOUSE_PASSWORD $admUser = $script:CFG_ADMIN_USER $admPass = $script:CFG_ADMIN_PASS $nodeTls = $script:CFG_NODE_TLS_REJECT $vendEnabled = $script:CFG_VENDOR_ENABLED $vendUser = $script:CFG_VENDOR_USER $vendPass = $script:CFG_VENDOR_PASS $dockerSock = $script:CFG_DOCKER_SOCKET $content = @" # Cameleer SaaS Configuration # Generated by installer v${CAMELEER_INSTALLER_VERSION} on ${utcNow} # Image version VERSION=${ver} # Public access PUBLIC_HOST=${host_} PUBLIC_PROTOCOL=${proto} # Ports HTTP_PORT=${httpPort} HTTPS_PORT=${httpsPort} LOGTO_CONSOLE_PORT=${logtoPort} # PostgreSQL POSTGRES_USER=cameleer POSTGRES_PASSWORD=${pgPass} POSTGRES_DB=cameleer_saas # ClickHouse CLICKHOUSE_PASSWORD=${chPass} # Admin user SAAS_ADMIN_USER=${admUser} SAAS_ADMIN_PASS=${admPass} # TLS NODE_TLS_REJECT=${nodeTls} "@ Write-LFFile $f $content if ($script:CFG_TLS_MODE -eq 'custom') { Append-LFFile $f "CERT_FILE=/user-certs/cert.pem`nKEY_FILE=/user-certs/key.pem`n" if ($script:CFG_CA_FILE -ne '') { Append-LFFile $f "CA_FILE=/user-certs/ca.pem`n" } } $vendorSection = @" # Vendor account VENDOR_SEED_ENABLED=${vendEnabled} VENDOR_USER=${vendUser} VENDOR_PASS=${vendPass} # Docker DOCKER_SOCKET=${dockerSock} # Provisioning images CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=${REGISTRY}/cameleer3-server:${ver} CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=${REGISTRY}/cameleer3-server-ui:${ver} "@ Append-LFFile $f $vendorSection Write-Info 'Generated .env' Copy-Item $f (Join-Path $script:CFG_INSTALL_DIR '.env.bak') -Force } # --------------------------------------------------------------------------- # docker-compose.yml generation # The template uses ${VAR} references that Docker Compose expands at runtime. # We use single-quoted PowerShell here-strings (@'...'@) to keep those # references as literal text — they must NOT be expanded by PowerShell. # --------------------------------------------------------------------------- function New-ComposeFile { $f = Join-Path $script:CFG_INSTALL_DIR 'docker-compose.yml' if (Test-Path $f) { Remove-Item $f -Force } # --- Header + traefik service start --- $header = @' # Cameleer SaaS Platform # Generated by Cameleer installer — 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" '@ Write-LFFile $f $header # Conditional: Logto console port exposure if ($script:CFG_LOGTO_CONSOLE_EXPOSED -eq 'true') { Append-LFFile $f @' - "${LOGTO_CONSOLE_PORT:-3002}:3002" '@ } Append-LFFile $f @' 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 '@ # Conditional: custom cert mount if ($script:CFG_TLS_MODE -eq 'custom') { Append-LFFile $f @' - ./certs:/user-certs:ro '@ } Append-LFFile $f @' networks: - cameleer - cameleer-traefik '@ # Conditional: monitoring network + labels for traefik if ($script:CFG_MONITORING_NETWORK -ne '') { Append-LFFile $f " - $($script:CFG_MONITORING_NETWORK)`n" Append-LFFile $f @' labels: - "prometheus.io/scrape=true" - "prometheus.io/port=8082" - "prometheus.io/path=/metrics" '@ } # --- postgres service --- Append-LFFile $f @' 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 '@ if ($script:CFG_MONITORING_NETWORK -ne '') { Append-LFFile $f " - $($script:CFG_MONITORING_NETWORK)`n" } # --- clickhouse service --- Append-LFFile $f @' 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 '@ if ($script:CFG_MONITORING_NETWORK -ne '') { Append-LFFile $f " - $($script:CFG_MONITORING_NETWORK)`n" Append-LFFile $f @' labels: - "prometheus.io/scrape=true" - "prometheus.io/port=9363" - "prometheus.io/path=/metrics" '@ } # --- logto service --- Append-LFFile $f @' 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} 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 '@ # Conditional: Logto console router labels if ($script:CFG_LOGTO_CONSOLE_EXPOSED -eq 'true') { Append-LFFile $f @' - 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 '@ } Append-LFFile $f @' 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: 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_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 '@ # Conditional: monitoring labels for cameleer-saas if ($script:CFG_MONITORING_NETWORK -ne '') { Append-LFFile $f @' - "prometheus.io/scrape=true" - "prometheus.io/port=8080" - "prometheus.io/path=/platform/actuator/prometheus" '@ } Append-LFFile $f @' volumes: - bootstrapdata:/data/bootstrap:ro - certs:/certs - ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock networks: - cameleer '@ if ($script:CFG_MONITORING_NETWORK -ne '') { Append-LFFile $f " - $($script:CFG_MONITORING_NETWORK)`n" } Append-LFFile $f @' group_add: - "0" volumes: pgdata: chdata: certs: bootstrapdata: networks: cameleer: driver: bridge cameleer-traefik: name: cameleer-traefik driver: bridge '@ # Conditional: external monitoring network declaration if ($script:CFG_MONITORING_NETWORK -ne '') { Append-LFFile $f " $($script:CFG_MONITORING_NETWORK):`n external: true`n" } Write-Info 'Generated docker-compose.yml' } # --------------------------------------------------------------------------- # cameleer.conf generation # --------------------------------------------------------------------------- function New-ConfigFile { $f = Join-Path $script:CFG_INSTALL_DIR 'cameleer.conf' $utcNow = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC') $content = @" # Cameleer installation config # Generated by installer v${CAMELEER_INSTALLER_VERSION} on ${utcNow} install_dir=$($script:CFG_INSTALL_DIR) public_host=$($script:CFG_PUBLIC_HOST) public_protocol=$($script:CFG_PUBLIC_PROTOCOL) admin_user=$($script:CFG_ADMIN_USER) tls_mode=$($script:CFG_TLS_MODE) http_port=$($script:CFG_HTTP_PORT) https_port=$($script:CFG_HTTPS_PORT) logto_console_port=$($script:CFG_LOGTO_CONSOLE_PORT) logto_console_exposed=$($script:CFG_LOGTO_CONSOLE_EXPOSED) vendor_enabled=$($script:CFG_VENDOR_ENABLED) vendor_user=$($script:CFG_VENDOR_USER) monitoring_network=$($script:CFG_MONITORING_NETWORK) version=$($script:CFG_VERSION) compose_project=$($script:CFG_COMPOSE_PROJECT) docker_socket=$($script:CFG_DOCKER_SOCKET) node_tls_reject=$($script:CFG_NODE_TLS_REJECT) "@ Write-LFFile $f $content Write-Info 'Saved installer config to cameleer.conf' } # --------------------------------------------------------------------------- # Docker operations # --------------------------------------------------------------------------- function Invoke-DockerPull { Write-Info 'Pulling Docker images...' Push-Location $script:CFG_INSTALL_DIR try { & docker compose -p $script:CFG_COMPOSE_PROJECT pull if ($LASTEXITCODE -ne 0) { throw 'docker compose pull failed' } } finally { Pop-Location } Write-Success 'All images pulled.' } function Invoke-DockerUp { Write-Info 'Starting Cameleer SaaS platform...' Push-Location $script:CFG_INSTALL_DIR try { & docker compose -p $script:CFG_COMPOSE_PROJECT up -d if ($LASTEXITCODE -ne 0) { throw 'docker compose up failed' } } finally { Pop-Location } Write-Info 'Containers started.' } function Invoke-DockerDown { Write-Info 'Stopping Cameleer SaaS platform...' Push-Location $script:CFG_INSTALL_DIR try { & docker compose -p $script:CFG_COMPOSE_PROJECT down } finally { Pop-Location } } # --------------------------------------------------------------------------- # Health verification # --------------------------------------------------------------------------- function Test-ServiceHealth { param( [string]$Name, [scriptblock]$CheckScript, [int]$TimeoutSecs = 120 ) $startTime = Get-Date while ($true) { try { $result = & $CheckScript if ($result -eq $true -or $LASTEXITCODE -eq 0) { $elapsed = [int]((Get-Date) - $startTime).TotalSeconds Write-Host (" [ok] {0,-20} ready ({1}s)" -f $Name, $elapsed) -ForegroundColor Green return $true } } catch { } $elapsed = [int]((Get-Date) - $startTime).TotalSeconds if ($elapsed -ge $TimeoutSecs) { Write-Host (" [FAIL] {0,-20} not ready after {1}s" -f $Name, $TimeoutSecs) -ForegroundColor Red $svcName = $Name.ToLower() -replace ' ', '-' Write-Host " Check: docker compose -p $($script:CFG_COMPOSE_PROJECT) logs $svcName" return $false } Start-Sleep -Seconds 5 } } function Invoke-HealthVerification { Write-Host '' Write-Info 'Verifying installation...' $failed = $false $proj = $script:CFG_COMPOSE_PROJECT $dir = $script:CFG_INSTALL_DIR $httpsPort = $script:CFG_HTTPS_PORT if (-not $failed) { $ok = Test-ServiceHealth 'PostgreSQL' { Push-Location $dir try { & docker compose -p $proj exec -T postgres pg_isready -U cameleer 2>$null; $LASTEXITCODE -eq 0 } finally { Pop-Location } } 120 if (-not $ok) { $failed = $true } } if (-not $failed) { $chPass = $script:CFG_CLICKHOUSE_PASSWORD $ok = Test-ServiceHealth 'ClickHouse' { Push-Location $dir try { & docker compose -p $proj exec -T clickhouse clickhouse-client --password $chPass --query 'SELECT 1' 2>$null $LASTEXITCODE -eq 0 } finally { Pop-Location } } 120 if (-not $ok) { $failed = $true } } if (-not $failed) { $ok = Test-ServiceHealth 'Logto' { Push-Location $dir try { & docker compose -p $proj exec -T logto 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))" 2>$null $LASTEXITCODE -eq 0 } finally { Pop-Location } } 300 if (-not $ok) { $failed = $true } } if (-not $failed) { $ok = Test-ServiceHealth 'Bootstrap' { Push-Location $dir try { & docker compose -p $proj exec -T logto test -f /data/logto-bootstrap.json 2>$null $LASTEXITCODE -eq 0 } finally { Pop-Location } } 300 if (-not $ok) { $failed = $true } } if (-not $failed) { $ok = Test-ServiceHealth 'Cameleer SaaS' { try { $response = Invoke-WebRequest -Uri "https://localhost:$httpsPort/platform/api/config" ` -UseBasicParsing -SkipCertificateCheck -ErrorAction SilentlyContinue $response.StatusCode -lt 400 } catch { $false } } 120 if (-not $ok) { $failed = $true } } if (-not $failed) { $ok = Test-ServiceHealth 'Traefik routing' { try { $response = Invoke-WebRequest -Uri "https://localhost:$httpsPort/" ` -UseBasicParsing -SkipCertificateCheck -ErrorAction SilentlyContinue $response.StatusCode -lt 500 } catch { $false } } 120 if (-not $ok) { $failed = $true } } Write-Host '' if ($failed) { Write-Err 'Installation verification failed. Stack is running — check logs.' exit 1 } Write-Success 'All services healthy.' } # --------------------------------------------------------------------------- # credentials.txt generation # --------------------------------------------------------------------------- function New-CredentialsFile { $f = Join-Path $script:CFG_INSTALL_DIR 'credentials.txt' $utcNow = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC') $proto = $script:CFG_PUBLIC_PROTOCOL $host_ = $script:CFG_PUBLIC_HOST $content = @" =========================================== CAMELEER PLATFORM CREDENTIALS Generated: ${utcNow} SECURE THIS FILE AND DELETE AFTER NOTING THESE CREDENTIALS CANNOT BE RECOVERED =========================================== Admin Console: ${proto}://${host_}/platform/ Admin User: $($script:CFG_ADMIN_USER) Admin Password: $($script:CFG_ADMIN_PASS) PostgreSQL: cameleer / $($script:CFG_POSTGRES_PASSWORD) ClickHouse: default / $($script:CFG_CLICKHOUSE_PASSWORD) "@ Write-LFFile $f $content if ($script:CFG_VENDOR_ENABLED -eq 'true') { Append-LFFile $f "Vendor User: $($script:CFG_VENDOR_USER)`nVendor Password: $($script:CFG_VENDOR_PASS)`n`n" } else { Append-LFFile $f "Vendor User: (not enabled)`n`n" } if ($script:CFG_LOGTO_CONSOLE_EXPOSED -eq 'true') { Append-LFFile $f "Logto Console: $($script:CFG_PUBLIC_PROTOCOL)://$($script:CFG_PUBLIC_HOST):$($script:CFG_LOGTO_CONSOLE_PORT)`n" } else { Append-LFFile $f "Logto Console: (not exposed)`n" } # Restrict file permissions (Windows: set to owner-only via ACL) try { $acl = Get-Acl $f $acl.SetAccessRuleProtection($true, $false) $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( [System.Security.Principal.WindowsIdentity]::GetCurrent().Name, 'FullControl', 'Allow') $acl.SetAccessRule($rule) Set-Acl $f $acl } catch { # Non-fatal — ACL manipulation may fail in containers / WSL scenarios } Write-Info 'Saved credentials to credentials.txt' } # --------------------------------------------------------------------------- # INSTALL.md generation # --------------------------------------------------------------------------- function New-InstallDoc { $f = Join-Path $script:CFG_INSTALL_DIR 'INSTALL.md' $utcNow = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC') $tlsDesc = if ($script:CFG_TLS_MODE -eq 'custom') { 'Custom certificate' } else { 'Self-signed (auto-generated)' } $proto = $script:CFG_PUBLIC_PROTOCOL $host_ = $script:CFG_PUBLIC_HOST $ver = $script:CFG_VERSION $dir = $script:CFG_INSTALL_DIR $proj = $script:CFG_COMPOSE_PROJECT $httpPort = $script:CFG_HTTP_PORT $httpsPort = $script:CFG_HTTPS_PORT $logtoPort = $script:CFG_LOGTO_CONSOLE_PORT $content = @" # Cameleer SaaS — Installation Documentation ## Installation Summary | | | |---|---| | **Version** | ${ver} | | **Date** | ${utcNow} | | **Installer** | v${CAMELEER_INSTALLER_VERSION} | | **Install Directory** | ${dir} | | **Hostname** | ${host_} | | **TLS** | ${tlsDesc} | ## Service URLs - **Platform UI:** ${proto}://${host_}/platform/ - **API Endpoint:** ${proto}://${host_}/platform/api/ "@ Write-LFFile $f $content if ($script:CFG_LOGTO_CONSOLE_EXPOSED -eq 'true') { Append-LFFile $f "- **Logto Admin Console:** ${proto}://${host_}:${logtoPort}`n" } Append-LFFile $f @' ## 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 '@ Append-LFFile $f "| Port | Service |`n|---|---|`n| ${httpPort} | HTTP (redirects to HTTPS) |`n| ${httpsPort} | HTTPS (main entry point) |`n" if ($script:CFG_LOGTO_CONSOLE_EXPOSED -eq 'true') { Append-LFFile $f "| ${logtoPort} | Logto Admin Console |`n" } if ($script:CFG_MONITORING_NETWORK -ne '') { $monNet = $script:CFG_MONITORING_NETWORK Append-LFFile $f "`n### Monitoring`n`nServices are connected to the \`${monNet}\` Docker network with Prometheus labels for auto-discovery.`n" } Append-LFFile $f "`n## TLS`n`n**Mode:** ${tlsDesc}`n" if ($script:CFG_TLS_MODE -eq 'self-signed') { Append-LFFile $f @' 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) '@ } Append-LFFile $f @" ## 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 ${proj} exec postgres pg_dump -U cameleer cameleer_saas > backup.sql # ClickHouse docker compose -p ${proj} exec clickhouse clickhouse-client --query "SELECT * FROM cameleer.traces FORMAT Native" > traces.native \`\`\` ## Upgrading Re-run the installer with a new version: \`\`\`powershell .\install.ps1 -InstallDir ${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 ${proj} logs SERVICE_NAME\` | | Bootstrap failed | \`docker compose -p ${proj} logs logto\` | | Routing issues | \`docker compose -p ${proj} logs traefik\` | | Database issues | \`docker compose -p ${proj} exec postgres psql -U cameleer -d cameleer_saas\` | ## Uninstalling \`\`\`bash # Stop and remove containers cd ${dir} && docker compose -p ${proj} down # Remove data volumes (DESTRUCTIVE) cd ${dir} && docker compose -p ${proj} down -v # Remove install directory Remove-Item -Recurse -Force ${dir} \`\`\` "@ Write-Info 'Generated INSTALL.md' } # --------------------------------------------------------------------------- # Print credentials to console # --------------------------------------------------------------------------- function Show-Credentials { Write-Host '' Write-Host '==========================================' -ForegroundColor White Write-Host ' CAMELEER PLATFORM CREDENTIALS' -ForegroundColor White Write-Host '==========================================' -ForegroundColor White Write-Host '' Write-Host " Admin Console: $($script:CFG_PUBLIC_PROTOCOL)://$($script:CFG_PUBLIC_HOST)/platform/" -ForegroundColor Cyan Write-Host " Admin User: $($script:CFG_ADMIN_USER)" -ForegroundColor White Write-Host " Admin Password: $($script:CFG_ADMIN_PASS)" -ForegroundColor White Write-Host '' Write-Host " PostgreSQL: cameleer / $($script:CFG_POSTGRES_PASSWORD)" Write-Host " ClickHouse: default / $($script:CFG_CLICKHOUSE_PASSWORD)" Write-Host '' if ($script:CFG_VENDOR_ENABLED -eq 'true') { Write-Host " Vendor User: $($script:CFG_VENDOR_USER)" -ForegroundColor White Write-Host " Vendor Password: $($script:CFG_VENDOR_PASS)" -ForegroundColor White Write-Host '' } if ($script:CFG_LOGTO_CONSOLE_EXPOSED -eq 'true') { Write-Host " Logto Console: $($script:CFG_PUBLIC_PROTOCOL)://$($script:CFG_PUBLIC_HOST):$($script:CFG_LOGTO_CONSOLE_PORT)" -ForegroundColor Cyan Write-Host '' } Write-Host " Credentials saved to: $($script:CFG_INSTALL_DIR)\credentials.txt" Write-Host ' Secure this file and delete after noting credentials.' -ForegroundColor Yellow Write-Host '' } # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- function Show-Summary { Write-Host '==========================================' -ForegroundColor Green Write-Host ' Installation complete!' -ForegroundColor Green Write-Host '==========================================' -ForegroundColor Green Write-Host '' Write-Host " Install directory: $($script:CFG_INSTALL_DIR)" Write-Host " Documentation: $($script:CFG_INSTALL_DIR)\INSTALL.md" Write-Host '' Write-Host ' To manage the stack:' Write-Host " cd $($script:CFG_INSTALL_DIR)" Write-Host " docker compose -p $($script:CFG_COMPOSE_PROJECT) ps # status" Write-Host " docker compose -p $($script:CFG_COMPOSE_PROJECT) logs -f # logs" Write-Host " docker compose -p $($script:CFG_COMPOSE_PROJECT) down # stop" Write-Host '' } # --------------------------------------------------------------------------- # Re-run / upgrade menu # --------------------------------------------------------------------------- function Show-RerunMenu { $confPath = Join-Path $script:CFG_INSTALL_DIR 'cameleer.conf' $currentVersion = 'unknown' $currentHost = 'unknown' if (Test-Path $confPath -PathType Leaf) { foreach ($line in [System.IO.File]::ReadAllLines($confPath)) { if ($line -match '^version=(.+)$') { $currentVersion = $Matches[1] } if ($line -match '^public_host=(.+)$') { $currentHost = $Matches[1] } } } Write-Host '' Write-Host "Existing Cameleer installation detected (v${currentVersion})" -ForegroundColor White Write-Host " Install directory: $($script:CFG_INSTALL_DIR)" Write-Host " Public host: $currentHost" Write-Host '' if ($script:MODE -eq 'silent') { if ($script:RERUN_ACTION -eq '') { $script:RERUN_ACTION = 'upgrade' } return } if ($script:RERUN_ACTION -ne '') { return } $newVersion = if ($script:CFG_VERSION -ne '') { $script:CFG_VERSION } else { $CAMELEER_DEFAULT_VERSION } Write-Host " [1] Upgrade to v${newVersion} (pull new images, update compose)" Write-Host ' [2] Reconfigure (re-run interactive setup, preserve data)' Write-Host ' [3] Reinstall (fresh install, WARNING: destroys data volumes)' Write-Host ' [4] Cancel' Write-Host '' $choice = Read-Host ' Select [1]' switch ($choice) { '1' { $script:RERUN_ACTION = 'upgrade' } '2' { $script:RERUN_ACTION = 'reconfigure' } '3' { $script:RERUN_ACTION = 'reinstall' } '4' { Write-Host 'Cancelled.'; exit 0 } '' { $script:RERUN_ACTION = 'upgrade' } default { Write-Host 'Invalid choice.'; exit 1 } } } function Invoke-Rerun { switch ($script:RERUN_ACTION) { 'upgrade' { Write-Info 'Upgrading installation...' Load-ConfigFile (Join-Path $script:CFG_INSTALL_DIR 'cameleer.conf') Load-EnvOverrides Merge-Config New-ComposeFile Invoke-DockerPull Invoke-DockerDown Invoke-DockerUp Invoke-HealthVerification New-InstallDoc Show-Summary exit 0 } 'reconfigure' { Write-Info 'Reconfiguring installation...' return } 'reinstall' { if (-not $script:CONFIRM_DESTROY) { Write-Host '' Write-Warn 'This will destroy ALL data (databases, certificates, bootstrap).' if (-not (Invoke-YesNoPrompt 'Are you sure? This cannot be undone.')) { Write-Host 'Cancelled.' exit 0 } } Write-Info 'Reinstalling...' try { Invoke-DockerDown } catch { } try { Push-Location $script:CFG_INSTALL_DIR & docker compose -p $script:CFG_COMPOSE_PROJECT down -v 2>$null Pop-Location } catch { } foreach ($file in @('.env', 'docker-compose.yml', 'cameleer.conf', 'credentials.txt', 'INSTALL.md', '.env.bak')) { $fp = Join-Path $script:CFG_INSTALL_DIR $file if (Test-Path $fp) { Remove-Item $fp -Force } } $certsPath = Join-Path $script:CFG_INSTALL_DIR 'certs' if (Test-Path $certsPath) { Remove-Item $certsPath -Recurse -Force } $script:IS_RERUN = $false return } } } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- function Invoke-Main { if ($Help) { Show-Help; exit 0 } Show-Banner # Load config sources (CLI already applied via param block) if ($Config -ne '') { Load-ConfigFile $Config } Load-EnvOverrides # Check for existing installation Find-ExistingInstall if ($script:IS_RERUN) { Show-RerunMenu Invoke-Rerun # If we reach here, action was 'reconfigure' or 'reinstall' (continues below) } # Prerequisites Test-Prerequisites # Auto-detect defaults Get-AutoDetectedDefaults # Interactive prompts (unless silent) if ($script:MODE -ne 'silent') { Select-InstallMode if ($script:MODE -eq 'expert') { Invoke-ExpertPrompts } else { Invoke-SimplePrompts } } # Merge remaining defaults and validate Merge-Config Test-Config # Generate passwords for any empty values New-Passwords # Create install directory if (-not (Test-Path $script:CFG_INSTALL_DIR)) { New-Item -ItemType Directory -Path $script:CFG_INSTALL_DIR -Force | Out-Null } # Copy custom certs if provided if ($script:CFG_TLS_MODE -eq 'custom') { Copy-Certificates } # Generate configuration files New-EnvFile New-ComposeFile New-ConfigFile # Pull and start Invoke-DockerPull Invoke-DockerUp # Verify health Invoke-HealthVerification # Generate output files New-CredentialsFile New-InstallDoc # Print results Show-Credentials Show-Summary } # Entry point Invoke-Main