#Requires -Version 5.1 <# .SYNOPSIS Cameleer SaaS Platform Installer .DESCRIPTION Installs the Cameleer SaaS platform using Docker Compose. .EXAMPLE .\install.ps1 .\install.ps1 -Expert .\install.ps1 -Silent -PublicHost myhost.example.com #> [CmdletBinding()] param( [switch]$Silent, [switch]$Expert, [string]$Config, [string]$InstallDir, [string]$PublicHost, [string]$AuthHost, [string]$PublicProtocol, [string]$AdminUser, [string]$AdminPassword, [string]$TlsMode, [string]$CertFile, [string]$KeyFile, [string]$CaFile, [string]$PostgresPassword, [string]$ClickhousePassword, [string]$HttpPort, [string]$HttpsPort, [string]$LogtoConsolePort, [string]$LogtoConsoleExposed, [string]$MonitoringNetwork, [string]$Version, [string]$ComposeProject, [string]$DockerSocket, [string]$NodeTlsReject, [string]$DeploymentMode, [string]$SmtpHost, [string]$SmtpPort, [string]$SmtpUser, [string]$SmtpPass, [string]$SmtpFromEmail, [switch]$Reconfigure, [switch]$Reinstall, [switch]$ConfirmDestroy, [switch]$Help ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # --- Constants --- $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_COMPOSE_PROJECT = 'cameleer-saas' $DEFAULT_COMPOSE_PROJECT_STANDALONE = 'cameleer' $DEFAULT_DOCKER_SOCKET = '/var/run/docker.sock' # --- Capture env vars before any overrides --- $_ENV_PUBLIC_HOST = $env:PUBLIC_HOST $_ENV_AUTH_HOST = $env:AUTH_HOST $_ENV_PUBLIC_PROTOCOL = $env:PUBLIC_PROTOCOL $_ENV_POSTGRES_PASSWORD = $env:POSTGRES_PASSWORD $_ENV_CLICKHOUSE_PASSWORD = $env:CLICKHOUSE_PASSWORD $_ENV_TLS_MODE = $env:TLS_MODE $_ENV_CERT_FILE = $env:CERT_FILE $_ENV_KEY_FILE = $env:KEY_FILE $_ENV_CA_FILE = $env:CA_FILE $_ENV_HTTP_PORT = $env:HTTP_PORT $_ENV_HTTPS_PORT = $env:HTTPS_PORT $_ENV_LOGTO_CONSOLE_PORT = $env:LOGTO_CONSOLE_PORT $_ENV_LOGTO_CONSOLE_EXPOSED = $env:LOGTO_CONSOLE_EXPOSED $_ENV_MONITORING_NETWORK = $env:MONITORING_NETWORK $_ENV_COMPOSE_PROJECT = $env:COMPOSE_PROJECT $_ENV_DOCKER_SOCKET = $env:DOCKER_SOCKET $_ENV_NODE_TLS_REJECT = $env:NODE_TLS_REJECT $_ENV_DEPLOYMENT_MODE = $env:DEPLOYMENT_MODE $_ENV_SMTP_HOST = $env:SMTP_HOST $_ENV_SMTP_PORT = $env:SMTP_PORT $_ENV_SMTP_USER = $env:SMTP_USER $_ENV_SMTP_PASS = $env:SMTP_PASS $_ENV_SMTP_FROM_EMAIL = $env:SMTP_FROM_EMAIL # --- Mutable config state --- $script:cfg = @{ InstallDir = $InstallDir PublicHost = $PublicHost AuthHost = $AuthHost PublicProtocol = $PublicProtocol AdminUser = $AdminUser AdminPass = $AdminPassword TlsMode = $TlsMode CertFile = $CertFile KeyFile = $KeyFile CaFile = $CaFile PostgresPassword = $PostgresPassword ClickhousePassword = $ClickhousePassword HttpPort = $HttpPort HttpsPort = $HttpsPort LogtoConsolePort = $LogtoConsolePort LogtoConsoleExposed = $LogtoConsoleExposed MonitoringNetwork = $MonitoringNetwork Version = $Version ComposeProject = $ComposeProject DockerSocket = $DockerSocket NodeTlsReject = $NodeTlsReject DeploymentMode = $DeploymentMode SmtpHost = $SmtpHost SmtpPort = $SmtpPort SmtpUser = $SmtpUser SmtpPass = $SmtpPass SmtpFromEmail = $SmtpFromEmail } if ($Silent) { $script:Mode = 'silent' } elseif ($Expert) { $script:Mode = 'expert' } else { $script:Mode = '' } if ($Reconfigure) { $script:RerunAction = 'reconfigure' } elseif ($Reinstall) { $script:RerunAction = 'reinstall' } else { $script:RerunAction = '' } $script:IsRerun = $false $script:ConfirmDestroy = $ConfirmDestroy.IsPresent # --- Logging --- function Log-Info { param([string]$msg) Write-Host "[INFO] $msg" -ForegroundColor Green } function Log-Warn { param([string]$msg) Write-Host "[WARN] $msg" -ForegroundColor Yellow } function Log-Error { param([string]$msg) Write-Host "[ERROR] $msg" -ForegroundColor Red } function Log-Success { param([string]$msg) Write-Host "[OK] $msg" -ForegroundColor Green } function Print-Banner { Write-Host '' Write-Host ' ____ _ ' -ForegroundColor Cyan Write-Host ' / ___|__ _ _______ ___ | | ___ ___ _ _ ' -ForegroundColor Cyan Write-Host '| | / _` | _ _ _ \ / _ \| |/ _ \/ _ \ `_|' -ForegroundColor Cyan Write-Host '| |__| (_| | | | | | | __/| | __/ __/ | ' -ForegroundColor Cyan Write-Host ' \____\__,_|_| |_| |_|\___||_|\___|\___|_| ' -ForegroundColor Cyan Write-Host '' Write-Host " SaaS Platform Installer v$CAMELEER_INSTALLER_VERSION" -ForegroundColor Cyan 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 ' -AuthHost HOST Auth domain for Logto (default: same as PublicHost)' 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 ' -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)' } # --- Helpers --- function Coalesce { param($a, $b) if ($a) { return $a } else { return $b } } function Prompt-Value { param([string]$Text, [string]$Default = '') if ($Default) { $inp = Read-Host " $Text [$Default]" if ([string]::IsNullOrWhiteSpace($inp)) { return $Default } return $inp } return Read-Host " $Text" } function Prompt-Password { param([string]$Text, [string]$Default = '') if ($Default) { $hint = '[********]' } else { $hint = '' } $secure = Read-Host " $Text $hint" -AsSecureString $plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto( [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)) if ([string]::IsNullOrWhiteSpace($plain) -and $Default) { return $Default } return $plain } function Prompt-YesNo { param([string]$Text, [string]$Default = 'n') if ($Default -eq 'y') { $inp = Read-Host " $Text [Y/n]" if ($inp -match '^[nN]') { return $false } else { return $true } } else { $inp = Read-Host " $Text [y/N]" return ($inp -match '^[yY]') } } function Generate-Password { $bytes = New-Object byte[] 32 [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) $b64 = [Convert]::ToBase64String($bytes) -replace '[/+=]', '' return $b64.Substring(0, [Math]::Min(32, $b64.Length)) } # Writes UTF-8 without BOM (PS5 Set-Content -Encoding UTF8 adds BOM) function Write-Utf8File { param([string]$Path, [string]$Content) $enc = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($Path, $Content, $enc) } # --- Config file --- function Load-ConfigFile { param([string]$FilePath) if (-not (Test-Path $FilePath)) { return } foreach ($line in Get-Content $FilePath) { if ($line -match '^\s*#' -or $line -match '^\s*$') { continue } if ($line -match '^\s*([^=]+?)\s*=\s*(.*?)\s*$') { $key = $Matches[1].Trim() $val = $Matches[2].Trim() switch ($key) { 'install_dir' { if (-not $script:cfg.InstallDir) { $script:cfg.InstallDir = $val } } 'public_host' { if (-not $script:cfg.PublicHost) { $script:cfg.PublicHost = $val } } 'auth_host' { if (-not $script:cfg.AuthHost) { $script:cfg.AuthHost = $val } } 'public_protocol' { if (-not $script:cfg.PublicProtocol) { $script:cfg.PublicProtocol = $val } } 'admin_user' { if (-not $script:cfg.AdminUser) { $script:cfg.AdminUser = $val } } 'admin_password' { if (-not $script:cfg.AdminPass) { $script:cfg.AdminPass = $val } } 'tls_mode' { if (-not $script:cfg.TlsMode) { $script:cfg.TlsMode = $val } } 'cert_file' { if (-not $script:cfg.CertFile) { $script:cfg.CertFile = $val } } 'key_file' { if (-not $script:cfg.KeyFile) { $script:cfg.KeyFile = $val } } 'ca_file' { if (-not $script:cfg.CaFile) { $script:cfg.CaFile = $val } } 'postgres_password' { if (-not $script:cfg.PostgresPassword) { $script:cfg.PostgresPassword = $val } } 'clickhouse_password' { if (-not $script:cfg.ClickhousePassword) { $script:cfg.ClickhousePassword = $val } } 'http_port' { if (-not $script:cfg.HttpPort) { $script:cfg.HttpPort = $val } } 'https_port' { if (-not $script:cfg.HttpsPort) { $script:cfg.HttpsPort = $val } } 'logto_console_port' { if (-not $script:cfg.LogtoConsolePort) { $script:cfg.LogtoConsolePort = $val } } 'logto_console_exposed' { if (-not $script:cfg.LogtoConsoleExposed) { $script:cfg.LogtoConsoleExposed = $val } } 'monitoring_network' { if (-not $script:cfg.MonitoringNetwork) { $script:cfg.MonitoringNetwork = $val } } 'version' { if (-not $script:cfg.Version) { $script:cfg.Version = $val } } 'compose_project' { if (-not $script:cfg.ComposeProject) { $script:cfg.ComposeProject = $val } } 'docker_socket' { if (-not $script:cfg.DockerSocket) { $script:cfg.DockerSocket = $val } } 'node_tls_reject' { if (-not $script:cfg.NodeTlsReject) { $script:cfg.NodeTlsReject = $val } } 'deployment_mode' { if (-not $script:cfg.DeploymentMode) { $script:cfg.DeploymentMode = $val } } 'smtp_host' { if (-not $script:cfg.SmtpHost) { $script:cfg.SmtpHost = $val } } 'smtp_port' { if (-not $script:cfg.SmtpPort) { $script:cfg.SmtpPort = $val } } 'smtp_user' { if (-not $script:cfg.SmtpUser) { $script:cfg.SmtpUser = $val } } 'smtp_pass' { if (-not $script:cfg.SmtpPass) { $script:cfg.SmtpPass = $val } } 'smtp_from_email' { if (-not $script:cfg.SmtpFromEmail) { $script:cfg.SmtpFromEmail = $val } } } } } } function Load-EnvOverrides { $c = $script:cfg if (-not $c.InstallDir) { $c.InstallDir = $env:CAMELEER_INSTALL_DIR } if (-not $c.PublicHost) { $c.PublicHost = $_ENV_PUBLIC_HOST } if (-not $c.AuthHost) { $c.AuthHost = $_ENV_AUTH_HOST } if (-not $c.PublicProtocol) { $c.PublicProtocol = $_ENV_PUBLIC_PROTOCOL } if (-not $c.AdminUser) { $c.AdminUser = $env:SAAS_ADMIN_USER } if (-not $c.AdminPass) { $c.AdminPass = $env:SAAS_ADMIN_PASS } if (-not $c.TlsMode) { $c.TlsMode = $_ENV_TLS_MODE } if (-not $c.CertFile) { $c.CertFile = $_ENV_CERT_FILE } if (-not $c.KeyFile) { $c.KeyFile = $_ENV_KEY_FILE } if (-not $c.CaFile) { $c.CaFile = $_ENV_CA_FILE } if (-not $c.PostgresPassword) { $c.PostgresPassword = $_ENV_POSTGRES_PASSWORD } if (-not $c.ClickhousePassword) { $c.ClickhousePassword = $_ENV_CLICKHOUSE_PASSWORD } if (-not $c.HttpPort) { $c.HttpPort = $_ENV_HTTP_PORT } if (-not $c.HttpsPort) { $c.HttpsPort = $_ENV_HTTPS_PORT } if (-not $c.LogtoConsolePort) { $c.LogtoConsolePort = $_ENV_LOGTO_CONSOLE_PORT } if (-not $c.LogtoConsoleExposed) { $c.LogtoConsoleExposed = $_ENV_LOGTO_CONSOLE_EXPOSED } if (-not $c.MonitoringNetwork) { $c.MonitoringNetwork = $_ENV_MONITORING_NETWORK } if (-not $c.Version) { $c.Version = $env:CAMELEER_VERSION } if (-not $c.ComposeProject) { $c.ComposeProject = $_ENV_COMPOSE_PROJECT } if (-not $c.DockerSocket) { $c.DockerSocket = $_ENV_DOCKER_SOCKET } if (-not $c.NodeTlsReject) { $c.NodeTlsReject = $_ENV_NODE_TLS_REJECT } if (-not $c.DeploymentMode) { $c.DeploymentMode = $_ENV_DEPLOYMENT_MODE } if (-not $c.SmtpHost) { $c.SmtpHost = $_ENV_SMTP_HOST } if (-not $c.SmtpPort) { $c.SmtpPort = $_ENV_SMTP_PORT } if (-not $c.SmtpUser) { $c.SmtpUser = $_ENV_SMTP_USER } if (-not $c.SmtpPass) { $c.SmtpPass = $_ENV_SMTP_PASS } if (-not $c.SmtpFromEmail) { $c.SmtpFromEmail = $_ENV_SMTP_FROM_EMAIL } } # --- Prerequisites --- function Check-PortAvailable { param([string]$Port, [string]$Name) try { $hits = netstat -an 2>$null | Select-String ":${Port} " if ($hits) { Log-Warn "Port $Port ($Name) appears to be in use." } } catch {} } function Check-Prerequisites { Log-Info 'Checking prerequisites...' $errors = 0 if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { Log-Error 'Docker is not installed.' Write-Host ' Install Docker Desktop: https://docs.docker.com/desktop/install/windows-install/' $errors++ } else { $v = docker version --format '{{.Server.Version}}' 2>$null Log-Info "Docker version: $v" } docker compose version 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { Log-Error "Docker Compose v2 not available. 'docker compose' subcommand required." $errors++ } else { $v = docker compose version --short 2>$null Log-Info "Docker Compose version: $v" } if (-not (Get-Command openssl -ErrorAction SilentlyContinue)) { Log-Warn 'OpenSSL not found -- using .NET RNG for passwords (fine).' } $socket = Coalesce $script:cfg.DockerSocket $DEFAULT_DOCKER_SOCKET if ($env:OS -eq 'Windows_NT') { if (-not (Test-Path '\\.\pipe\docker_engine' -ErrorAction SilentlyContinue)) { Log-Warn 'Docker named pipe not found. Is Docker Desktop running?' } } elseif (-not (Test-Path $socket)) { Log-Warn "Docker socket not found at $socket" } Check-PortAvailable (Coalesce $script:cfg.HttpPort $DEFAULT_HTTP_PORT) 'HTTP' Check-PortAvailable (Coalesce $script:cfg.HttpsPort $DEFAULT_HTTPS_PORT) 'HTTPS' if ($script:cfg.DeploymentMode -ne 'standalone') { Check-PortAvailable (Coalesce $script:cfg.LogtoConsolePort $DEFAULT_LOGTO_CONSOLE_PORT) 'Logto Console' } if ($errors -gt 0) { Log-Error "$errors prerequisite(s) not met. Please install missing dependencies and retry." exit 1 } Log-Success 'All prerequisites met.' } # --- Auto-detection --- function Auto-Detect { if (-not $script:cfg.PublicHost) { $detectedHost = $null # Try reverse DNS on each host IP — picks up FQDN from DNS server try { foreach ($addr in [System.Net.Dns]::GetHostAddresses([System.Net.Dns]::GetHostName())) { if ($addr.AddressFamily -ne 'InterNetwork') { continue } # IPv4 only if ($addr.ToString().StartsWith('127.')) { continue } try { $rev = [System.Net.Dns]::GetHostEntry($addr).HostName if ($rev -and $rev.Contains('.')) { $detectedHost = $rev break } } catch {} } } catch {} if (-not $detectedHost) { # Fallback: .NET forward lookup, then bare hostname try { $detectedHost = [System.Net.Dns]::GetHostEntry([System.Net.Dns]::GetHostName()).HostName } catch { $detectedHost = [System.Net.Dns]::GetHostName() } } $script:cfg.PublicHost = $detectedHost.ToLower() } if (-not $script:cfg.DockerSocket) { # Always use /var/run/docker.sock — containers are Linux and Docker Desktop # maps the host socket into the VM automatically. The Windows named pipe # (//./pipe/docker_engine) does NOT work as a volume mount for Linux containers. $script:cfg.DockerSocket = $DEFAULT_DOCKER_SOCKET } } function Detect-ExistingInstall { $dir = Coalesce $script:cfg.InstallDir $DEFAULT_INSTALL_DIR $confPath = Join-Path $dir 'cameleer.conf' if (Test-Path $confPath) { $script:IsRerun = $true $script:cfg.InstallDir = $dir Load-ConfigFile $confPath } } # --- Interactive prompts --- function Select-Mode { if ($script:Mode) { 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' } } function Run-SimplePrompts { $c = $script:cfg Write-Host '' Write-Host '--- Simple Installation ---' -ForegroundColor Cyan Write-Host '' $c.InstallDir = Prompt-Value 'Install directory' (Coalesce $c.InstallDir $DEFAULT_INSTALL_DIR) $c.PublicHost = Prompt-Value 'Public hostname' (Coalesce $c.PublicHost 'localhost') $c.AdminUser = Prompt-Value 'Admin username' (Coalesce $c.AdminUser $DEFAULT_ADMIN_USER) if (Prompt-YesNo 'Auto-generate admin password?' 'y') { $c.AdminPass = '' } else { $c.AdminPass = Prompt-Password 'Admin password' } Write-Host '' if (Prompt-YesNo 'Use custom TLS certificates? (no = self-signed)') { $c.TlsMode = 'custom' $c.CertFile = Prompt-Value 'Path to certificate file (PEM)' $c.KeyFile = Prompt-Value 'Path to private key file (PEM)' if (Prompt-YesNo 'Include CA bundle?') { $c.CaFile = Prompt-Value 'Path to CA bundle (PEM)' } } else { $c.TlsMode = 'self-signed' } Write-Host '' $c.MonitoringNetwork = Prompt-Value 'Monitoring network name (empty = skip)' '' Write-Host '' Write-Host ' Deployment mode:' Write-Host ' [1] Multi-tenant SaaS -- manage platform, provision tenants on demand' Write-Host ' [2] Single-tenant -- one server instance, local auth, no identity provider' Write-Host '' $deployChoice = Read-Host ' Select mode [1]' if ($deployChoice -eq '2') { $c.DeploymentMode = 'standalone' } else { $c.DeploymentMode = 'saas' } # SMTP for email verification (SaaS mode only) if ($c.DeploymentMode -eq 'saas') { Write-Host '' if (Prompt-YesNo 'Configure SMTP for email verification? (required for self-service sign-up)') { $c.SmtpHost = Prompt-Value 'SMTP host' (Coalesce $c.SmtpHost '') $c.SmtpPort = Prompt-Value 'SMTP port' (Coalesce $c.SmtpPort '587') $c.SmtpUser = Prompt-Value 'SMTP username' (Coalesce $c.SmtpUser '') $c.SmtpPass = Prompt-Password 'SMTP password' (Coalesce $c.SmtpPass '') $c.SmtpFromEmail = Prompt-Value 'From email address' (Coalesce $c.SmtpFromEmail "noreply@$($c.PublicHost)") } } } function Run-ExpertPrompts { $c = $script:cfg Run-SimplePrompts Write-Host '' Write-Host ' Credentials:' -ForegroundColor Cyan if (Prompt-YesNo 'Auto-generate database passwords?' 'y') { $c.PostgresPassword = '' $c.ClickhousePassword = '' } else { $c.PostgresPassword = Prompt-Password 'PostgreSQL password' $c.ClickhousePassword = Prompt-Password 'ClickHouse password' } Write-Host '' Write-Host ' Networking:' -ForegroundColor Cyan $c.HttpPort = Prompt-Value 'HTTP port' (Coalesce $c.HttpPort $DEFAULT_HTTP_PORT) $c.HttpsPort = Prompt-Value 'HTTPS port' (Coalesce $c.HttpsPort $DEFAULT_HTTPS_PORT) if ($c.DeploymentMode -eq 'saas') { $c.LogtoConsolePort = Prompt-Value 'Logto admin console port' (Coalesce $c.LogtoConsolePort $DEFAULT_LOGTO_CONSOLE_PORT) } Write-Host '' Write-Host ' Docker:' -ForegroundColor Cyan $c.Version = Prompt-Value 'Image version/tag' (Coalesce $c.Version $CAMELEER_DEFAULT_VERSION) $c.ComposeProject = Prompt-Value 'Compose project name' (Coalesce $c.ComposeProject $DEFAULT_COMPOSE_PROJECT) $c.DockerSocket = Prompt-Value 'Docker socket path' (Coalesce $c.DockerSocket $DEFAULT_DOCKER_SOCKET) if ($c.DeploymentMode -eq 'saas') { Write-Host '' Write-Host ' Logto:' -ForegroundColor Cyan $c.AuthHost = Prompt-Value 'Auth domain (Logto) -- same as hostname for single-domain' (Coalesce $c.AuthHost $c.PublicHost) if (Prompt-YesNo 'Expose Logto admin console externally?' 'y') { $c.LogtoConsoleExposed = 'true' } else { $c.LogtoConsoleExposed = 'false' } } } # --- Config merge & validation --- function Merge-Config { $c = $script:cfg if (-not $c.DeploymentMode) { $c.DeploymentMode = 'saas' } if (-not $c.InstallDir) { $c.InstallDir = $DEFAULT_INSTALL_DIR } if (-not $c.PublicHost) { $c.PublicHost = 'localhost' } if (-not $c.PublicProtocol) { $c.PublicProtocol = $DEFAULT_PUBLIC_PROTOCOL } if (-not $c.AdminUser) { $c.AdminUser = $DEFAULT_ADMIN_USER } if (-not $c.TlsMode) { $c.TlsMode = $DEFAULT_TLS_MODE } if (-not $c.HttpPort) { $c.HttpPort = $DEFAULT_HTTP_PORT } if (-not $c.HttpsPort) { $c.HttpsPort = $DEFAULT_HTTPS_PORT } if (-not $c.LogtoConsolePort) { $c.LogtoConsolePort = $DEFAULT_LOGTO_CONSOLE_PORT } if (-not $c.LogtoConsoleExposed) { $c.LogtoConsoleExposed = $DEFAULT_LOGTO_CONSOLE_EXPOSED } if (-not $c.Version) { $c.Version = $CAMELEER_DEFAULT_VERSION } if (-not $c.DockerSocket) { $c.DockerSocket = $DEFAULT_DOCKER_SOCKET } if (-not $c.ComposeProject) { if ($c.DeploymentMode -eq 'standalone') { $c.ComposeProject = $DEFAULT_COMPOSE_PROJECT_STANDALONE } else { $c.ComposeProject = $DEFAULT_COMPOSE_PROJECT } } # Default AUTH_HOST to PUBLIC_HOST (single-domain setup) if (-not $c.AuthHost) { $c.AuthHost = $c.PublicHost } # Force lowercase -- Logto normalises internally; case mismatch breaks JWT validation $c.PublicHost = $c.PublicHost.ToLower() $c.AuthHost = $c.AuthHost.ToLower() if ($c.DeploymentMode -ne 'standalone' -and (-not $c.NodeTlsReject)) { if ($c.TlsMode -eq 'custom') { $c.NodeTlsReject = '1' } else { $c.NodeTlsReject = '0' } } } function Validate-Config { $c = $script:cfg $errors = 0 if ($c.TlsMode -eq 'custom') { if (-not (Test-Path $c.CertFile)) { Log-Error "Certificate file not found: $($c.CertFile)"; $errors++ } if (-not (Test-Path $c.KeyFile)) { Log-Error "Key file not found: $($c.KeyFile)"; $errors++ } if ($c.CaFile -and -not (Test-Path $c.CaFile)) { Log-Error "CA bundle not found: $($c.CaFile)"; $errors++ } } foreach ($portKey in @('HttpPort','HttpsPort')) { $val = $c[$portKey]; $num = 0 if (-not ([int]::TryParse($val, [ref]$num)) -or $num -lt 1 -or $num -gt 65535) { Log-Error "Invalid port for ${portKey}: $val"; $errors++ } } if ($c.DeploymentMode -ne 'standalone') { $val = $c.LogtoConsolePort; $num = 0 if (-not ([int]::TryParse($val, [ref]$num)) -or $num -lt 1 -or $num -gt 65535) { Log-Error "Invalid port for LogtoConsolePort: $val"; $errors++ } } if ($errors -gt 0) { Log-Error 'Configuration validation failed.'; exit 1 } Log-Success 'Configuration validated.' } function Generate-Passwords { $c = $script:cfg if (-not $c.AdminPass) { $c.AdminPass = Generate-Password; Log-Info 'Generated admin password.' } if (-not $c.PostgresPassword) { $c.PostgresPassword = Generate-Password; Log-Info 'Generated PostgreSQL password.' } if (-not $c.ClickhousePassword) { $c.ClickhousePassword = Generate-Password; Log-Info 'Generated ClickHouse password.' } } # --- File helpers --- function Get-DockerGid { param([string]$Socket) if ($env:OS -ne 'Windows_NT') { try { $g = (& stat -c '%g' $Socket 2>$null); if ($g) { return $g } } catch {} } return '0' } function Copy-Certs { $c = $script:cfg $d = Join-Path $c.InstallDir 'certs' New-Item -ItemType Directory -Force -Path $d | Out-Null Copy-Item $c.CertFile (Join-Path $d 'cert.pem') -Force Copy-Item $c.KeyFile (Join-Path $d 'key.pem') -Force if ($c.CaFile) { Copy-Item $c.CaFile (Join-Path $d 'ca.pem') -Force } Log-Info "Copied TLS certificates to $d" } # --- .env generation --- function Generate-EnvFile { $c = $script:cfg $f = Join-Path $c.InstallDir '.env' $gid = Get-DockerGid $c.DockerSocket $ts = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' UTC' $bt = Generate-Password $jwtSecret = Generate-Password if ($c.DeploymentMode -eq 'standalone') { $content = @" # Cameleer Server Configuration (standalone) # Generated by installer v${CAMELEER_INSTALLER_VERSION} on $ts VERSION=$($c.Version) PUBLIC_HOST=$($c.PublicHost) PUBLIC_PROTOCOL=$($c.PublicProtocol) HTTP_PORT=$($c.HttpPort) HTTPS_PORT=$($c.HttpsPort) # PostgreSQL POSTGRES_USER=cameleer POSTGRES_PASSWORD=$($c.PostgresPassword) POSTGRES_DB=cameleer # ClickHouse CLICKHOUSE_PASSWORD=$($c.ClickhousePassword) # Server admin SERVER_ADMIN_USER=$($c.AdminUser) SERVER_ADMIN_PASS=$($c.AdminPass) # Bootstrap token BOOTSTRAP_TOKEN=$bt # JWT signing secret (required by server, must be non-empty) CAMELEER_SERVER_SECURITY_JWTSECRET=$jwtSecret # Docker DOCKER_SOCKET=$($c.DockerSocket) DOCKER_GID=$gid POSTGRES_IMAGE=postgres:16-alpine "@ if ($c.TlsMode -eq 'custom') { $content += "`nCERT_FILE=/user-certs/cert.pem" $content += "`nKEY_FILE=/user-certs/key.pem" if ($c.CaFile) { $content += "`nCA_FILE=/user-certs/ca.pem" } } $composeFile = 'docker-compose.yml;docker-compose.server.yml' if ($c.TlsMode -eq 'custom') { $composeFile += ';docker-compose.tls.yml' } if ($c.MonitoringNetwork) { $composeFile += ';docker-compose.monitoring.yml' } $content += "`n`n# Compose file assembly`nCOMPOSE_FILE=$composeFile" if ($c.MonitoringNetwork) { $content += "`n`n# Monitoring`nMONITORING_NETWORK=$($c.MonitoringNetwork)" } } else { $consoleBind = if ($c.LogtoConsoleExposed -eq 'true') { '0.0.0.0' } else { '127.0.0.1' } $content = @" # Cameleer SaaS Configuration # Generated by installer v${CAMELEER_INSTALLER_VERSION} on $ts VERSION=$($c.Version) PUBLIC_HOST=$($c.PublicHost) AUTH_HOST=$($c.AuthHost) PUBLIC_PROTOCOL=$($c.PublicProtocol) HTTP_PORT=$($c.HttpPort) HTTPS_PORT=$($c.HttpsPort) LOGTO_CONSOLE_PORT=$($c.LogtoConsolePort) LOGTO_CONSOLE_BIND=$consoleBind # PostgreSQL POSTGRES_USER=cameleer POSTGRES_PASSWORD=$($c.PostgresPassword) POSTGRES_DB=cameleer_saas # ClickHouse CLICKHOUSE_PASSWORD=$($c.ClickhousePassword) # Admin user SAAS_ADMIN_USER=$($c.AdminUser) SAAS_ADMIN_PASS=$($c.AdminPass) # TLS NODE_TLS_REJECT=$($c.NodeTlsReject) "@ if ($c.TlsMode -eq 'custom') { $content += "`nCERT_FILE=/user-certs/cert.pem" $content += "`nKEY_FILE=/user-certs/key.pem" if ($c.CaFile) { $content += "`nCA_FILE=/user-certs/ca.pem" } } $provisioningBlock = @" # Docker DOCKER_SOCKET=$($c.DockerSocket) DOCKER_GID=$gid # Provisioning images CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=${REGISTRY}/cameleer-server:$($c.Version) CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=${REGISTRY}/cameleer-server-ui:$($c.Version) CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE=${REGISTRY}/cameleer-runtime-base:$($c.Version) # JWT signing secret (forwarded to provisioned tenant servers, must be non-empty) CAMELEER_SERVER_SECURITY_JWTSECRET=$jwtSecret # SMTP (for email verification during registration) SMTP_HOST=$($c.SmtpHost) SMTP_PORT=$(if ($c.SmtpPort) { $c.SmtpPort } else { '587' }) SMTP_USER=$($c.SmtpUser) SMTP_PASS=$($c.SmtpPass) SMTP_FROM_EMAIL=$(if ($c.SmtpFromEmail) { $c.SmtpFromEmail } else { "noreply@$($c.PublicHost)" }) "@ $content += $provisioningBlock $composeFile = 'docker-compose.yml;docker-compose.saas.yml' if ($c.TlsMode -eq 'custom') { $composeFile += ';docker-compose.tls.yml' } if ($c.MonitoringNetwork) { $composeFile += ';docker-compose.monitoring.yml' } $content += "`n`n# Compose file assembly`nCOMPOSE_FILE=$composeFile" if ($c.MonitoringNetwork) { $content += "`n`n# Monitoring`nMONITORING_NETWORK=$($c.MonitoringNetwork)" } } Write-Utf8File $f $content Copy-Item $f (Join-Path $c.InstallDir '.env.bak') -Force Log-Info 'Generated .env' } # --- Copy docker-compose templates --- function Copy-Templates { $c = $script:cfg $src = Join-Path $PSScriptRoot 'templates' # Base infra -- always copied Copy-Item (Join-Path $src 'docker-compose.yml') (Join-Path $c.InstallDir 'docker-compose.yml') -Force Copy-Item (Join-Path $src '.env.example') (Join-Path $c.InstallDir '.env.example') -Force # Mode-specific if ($c.DeploymentMode -eq 'standalone') { Copy-Item (Join-Path $src 'docker-compose.server.yml') (Join-Path $c.InstallDir 'docker-compose.server.yml') -Force Copy-Item (Join-Path $src 'traefik-dynamic.yml') (Join-Path $c.InstallDir 'traefik-dynamic.yml') -Force } else { Copy-Item (Join-Path $src 'docker-compose.saas.yml') (Join-Path $c.InstallDir 'docker-compose.saas.yml') -Force } # Optional overlays if ($c.TlsMode -eq 'custom') { Copy-Item (Join-Path $src 'docker-compose.tls.yml') (Join-Path $c.InstallDir 'docker-compose.tls.yml') -Force } if ($c.MonitoringNetwork) { Copy-Item (Join-Path $src 'docker-compose.monitoring.yml') (Join-Path $c.InstallDir 'docker-compose.monitoring.yml') -Force } Log-Info "Copied docker-compose templates to $($c.InstallDir)" } # --- Docker operations --- function Invoke-ComposePull { $c = $script:cfg Log-Info 'Pulling Docker images...' Push-Location $c.InstallDir try { docker compose -p $c.ComposeProject pull } finally { Pop-Location } Log-Success 'All images pulled.' } function Invoke-ComposeUp { $c = $script:cfg Log-Info 'Starting Cameleer platform...' Push-Location $c.InstallDir try { docker compose -p $c.ComposeProject up -d --pull always --force-recreate } finally { Pop-Location } Log-Info 'Containers started -- verifying health next.' } function Invoke-ComposeDown { $c = $script:cfg Log-Info 'Stopping Cameleer platform...' Push-Location $c.InstallDir try { docker compose -p $c.ComposeProject down } finally { Pop-Location } } # --- Health verification --- function Enable-TrustAllCerts { # PS5.1 has no -SkipCertificateCheck on Invoke-WebRequest. # Bypass SSL validation globally for this session via the ServicePointManager. try { Add-Type -TypeDefinition @' using System.Net; using System.Security.Cryptography.X509Certificates; public class TrustAllCertsPolicy : ICertificatePolicy { public bool CheckValidationResult(ServicePoint sp, X509Certificate cert, WebRequest req, int problem) { return true; } } '@ } catch {} # already loaded -- ignore [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 -bor [System.Net.SecurityProtocolType]::Tls11 -bor [System.Net.SecurityProtocolType]::Tls } function Wait-DockerHealthy { param([string]$Name, [string]$Service, [int]$TimeoutSecs = 300) $c = $script:cfg $start = Get-Date $lastStatus = '' while ($true) { $elapsed = [int]((Get-Date) - $start).TotalSeconds if ($elapsed -ge $TimeoutSecs) { Write-Host (" [FAIL] {0,-20} not healthy after {1}s" -f $Name, $TimeoutSecs) -ForegroundColor Red Write-Host " Check: docker compose -p $($c.ComposeProject) logs $Service" return $false } # Resolve container ID first, then use docker inspect for reliable health status. # 'docker compose ps --format {{.Health}}' is inconsistent across Docker Desktop versions. Push-Location $c.InstallDir $cid = (docker compose -p $c.ComposeProject ps -q $Service 2>$null) | Select-Object -First 1 Pop-Location $health = '' if ($cid) { $health = (docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' $cid 2>$null).Trim() } if ($health -eq 'healthy') { $dur = [int]((Get-Date) - $start).TotalSeconds Write-Host (" [ok] {0,-20} ready ({1}s)" -f $Name, $dur) -ForegroundColor Green return $true } elseif ($health -eq 'unhealthy') { Write-Host (" [FAIL] {0,-20} unhealthy" -f $Name) -ForegroundColor Red Write-Host " Check: docker compose -p $($c.ComposeProject) logs $Service" return $false } else { # Print a progress dot every 15 seconds so the user knows we haven't hung if ($elapsed -gt 0 -and ($elapsed % 15) -lt 3 -and $elapsed -ne $lastStatus) { $lastStatus = $elapsed Write-Host (" [wait] {0,-20} {1}s elapsed (status: {2})" -f $Name, $elapsed, $(if ($health) { $health } else { 'starting' })) } Start-Sleep 3 } } } function Test-Endpoint { param([string]$Name, [string]$Url, [int]$TimeoutSecs = 120) $start = Get-Date $lastDot = -1 while ($true) { $elapsed = [int]((Get-Date) - $start).TotalSeconds if ($elapsed -ge $TimeoutSecs) { Write-Host (" [FAIL] {0,-20} not reachable after {1}s" -f $Name, $TimeoutSecs) -ForegroundColor Red return $false } try { # -SkipCertificateCheck is PS6+ only; SSL trust is handled by Enable-TrustAllCerts above $resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop $dur = [int]((Get-Date) - $start).TotalSeconds Write-Host (" [ok] {0,-20} ready ({1}s)" -f $Name, $dur) -ForegroundColor Green return $true } catch { if ($elapsed -gt 0 -and ($elapsed % 15) -lt 3 -and $elapsed -ne $lastDot) { $lastDot = $elapsed Write-Host (" [wait] {0,-20} {1}s elapsed" -f $Name, $elapsed) } Start-Sleep 3 } } } function Verify-Health { $c = $script:cfg Write-Host '' Log-Info 'Verifying installation...' Enable-TrustAllCerts # allow self-signed certs for PS5.1 Invoke-WebRequest $failed = $false if (-not (Wait-DockerHealthy 'PostgreSQL' 'cameleer-postgres' 120)) { $failed = $true } if (-not $failed) { if (-not (Wait-DockerHealthy 'ClickHouse' 'cameleer-clickhouse' 120)) { $failed = $true } } if ($c.DeploymentMode -eq 'standalone') { if (-not $failed) { if (-not (Wait-DockerHealthy 'Cameleer Server' 'cameleer-server' 300)) { $failed = $true } } if (-not $failed) { if (-not (Test-Endpoint 'Server UI' "https://localhost:$($c.HttpsPort)/" 60)) { $failed = $true } } } else { if (-not $failed) { if (-not (Wait-DockerHealthy 'Logto + Bootstrap' 'cameleer-logto' 300)) { $failed = $true } } if (-not $failed) { if (-not (Test-Endpoint 'Cameleer SaaS' "https://localhost:$($c.HttpsPort)/platform/api/config" 120)) { $failed = $true } } if (-not $failed) { if (-not (Test-Endpoint 'Traefik routing' "https://localhost:$($c.HttpsPort)/" 30)) { $failed = $true } } } Write-Host '' if ($failed) { Log-Error 'Installation verification failed. Stack is running -- check logs.'; exit 1 } Log-Success 'All services healthy.' } # --- Output files --- function Write-ConfigFile { $c = $script:cfg $f = Join-Path $c.InstallDir 'cameleer.conf' $ts = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' UTC' $txt = @" # Cameleer installation config # Generated by installer v${CAMELEER_INSTALLER_VERSION} on $ts install_dir=$($c.InstallDir) public_host=$($c.PublicHost) auth_host=$($c.AuthHost) public_protocol=$($c.PublicProtocol) admin_user=$($c.AdminUser) tls_mode=$($c.TlsMode) http_port=$($c.HttpPort) https_port=$($c.HttpsPort) logto_console_port=$($c.LogtoConsolePort) logto_console_exposed=$($c.LogtoConsoleExposed) monitoring_network=$($c.MonitoringNetwork) version=$($c.Version) compose_project=$($c.ComposeProject) docker_socket=$($c.DockerSocket) node_tls_reject=$($c.NodeTlsReject) deployment_mode=$($c.DeploymentMode) smtp_host=$($c.SmtpHost) smtp_port=$($c.SmtpPort) smtp_user=$($c.SmtpUser) smtp_pass=$($c.SmtpPass) smtp_from_email=$($c.SmtpFromEmail) "@ Write-Utf8File $f $txt Log-Info 'Saved installer config to cameleer.conf' } function Generate-CredentialsFile { $c = $script:cfg $f = Join-Path $c.InstallDir 'credentials.txt' $ts = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' UTC' if ($c.DeploymentMode -eq 'standalone') { $txt = @" =========================================== CAMELEER SERVER CREDENTIALS Generated: $ts SECURE THIS FILE AND DELETE AFTER NOTING THESE CREDENTIALS CANNOT BE RECOVERED =========================================== Server Dashboard: $($c.PublicProtocol)://$($c.PublicHost)/ Admin User: $($c.AdminUser) Admin Password: $($c.AdminPass) PostgreSQL: cameleer / $($c.PostgresPassword) ClickHouse: default / $($c.ClickhousePassword) "@ } else { if ($c.LogtoConsoleExposed -eq 'true') { $logtoLine = "Logto Console: $($c.PublicProtocol)://$($c.AuthHost):$($c.LogtoConsolePort)" } else { $logtoLine = 'Logto Console: (not exposed)' } $txt = @" =========================================== CAMELEER PLATFORM CREDENTIALS Generated: $ts SECURE THIS FILE AND DELETE AFTER NOTING THESE CREDENTIALS CANNOT BE RECOVERED =========================================== Admin Console: $($c.PublicProtocol)://$($c.PublicHost)/platform/ Admin User: $($c.AdminUser) Admin Password: $($c.AdminPass) PostgreSQL: cameleer / $($c.PostgresPassword) ClickHouse: default / $($c.ClickhousePassword) $logtoLine "@ } Write-Utf8File $f $txt # Restrict permissions on Windows (best effort) try { $acl = Get-Acl $f $acl.SetAccessRuleProtection($true, $false) $rule = New-Object Security.AccessControl.FileSystemAccessRule( [Security.Principal.WindowsIdentity]::GetCurrent().Name, 'FullControl', 'Allow') $acl.SetAccessRule($rule) Set-Acl $f $acl } catch {} Log-Info 'Saved credentials to credentials.txt' } function Generate-InstallDoc { $c = $script:cfg if ($c.DeploymentMode -eq 'standalone') { Generate-InstallDocStandalone; return } $f = Join-Path $c.InstallDir 'INSTALL.md' $ts = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' UTC' if ($c.TlsMode -eq 'custom') { $tlsDesc = 'Custom certificate' } else { $tlsDesc = 'Self-signed (auto-generated)' } if ($c.LogtoConsoleExposed -eq 'true') { $logtoConsoleRow = "- **Logto Admin Console:** $($c.PublicProtocol)://$($c.AuthHost):$($c.LogtoConsolePort)" $logtoPortRow = "| $($c.LogtoConsolePort) | Logto Admin Console |" } else { $logtoConsoleRow = '' $logtoPortRow = '' } if ($c.MonitoringNetwork) { $monSection = @" ### Monitoring Services are connected to the ``$($c.MonitoringNetwork)`` Docker network with Prometheus labels for auto-discovery. "@ } else { $monSection = '' } if ($c.TlsMode -eq 'self-signed') { $tlsSection = @' The platform generated a self-signed certificate on first boot. To replace it: 1. Log in as admin and navigate to **Certificates** in the admin console 2. Upload your certificate and key via the UI 3. Activate the new certificate (zero-downtime swap) '@ } else { $tlsSection = '' } $txt = @" # Cameleer SaaS -- Installation Documentation ## Installation Summary | | | |---|---| | **Version** | $($c.Version) | | **Date** | $ts | | **Installer** | v${CAMELEER_INSTALLER_VERSION} | | **Install Directory** | $($c.InstallDir) | | **Hostname** | $($c.PublicHost) | | **TLS** | $tlsDesc | ## Service URLs - **Platform UI:** $($c.PublicProtocol)://$($c.PublicHost)/platform/ - **API Endpoint:** $($c.PublicProtocol)://$($c.PublicHost)/platform/api/ $logtoConsoleRow ## First Steps 1. Open the Platform UI in your browser 2. Log in as admin with the credentials from ``credentials.txt`` 3. Create tenants from the admin console 4. The platform will provision a dedicated server instance for each tenant ## Architecture | Container | Purpose | |---|---| | ``cameleer-traefik`` | Reverse proxy, TLS termination, routing | | ``cameleer-postgres`` | PostgreSQL database (SaaS + Logto + tenant schemas) | | ``cameleer-clickhouse`` | Time-series storage (traces, metrics, logs) | | ``cameleer-logto`` | OIDC identity provider + bootstrap | | ``cameleer-saas`` | SaaS platform (Spring Boot + React) | Per-tenant ``cameleer-server`` and ``cameleer-server-ui`` containers are provisioned dynamically. ## Networking | Port | Service | |---|---| | $($c.HttpPort) | HTTP (redirects to HTTPS) | | $($c.HttpsPort) | HTTPS (main entry point) | $logtoPortRow $monSection ## TLS **Mode:** $tlsDesc $tlsSection ## Data & Backups | Docker Volume | Contains | |---|---| | ``cameleer-pgdata`` | PostgreSQL data (tenants, licenses, audit) | | ``cameleer-chdata`` | ClickHouse data (traces, metrics, logs) | | ``cameleer-certs`` | TLS certificates | | ``cameleer-bootstrapdata`` | Logto bootstrap results | ### Backup Commands ``````bash docker compose -p $($c.ComposeProject) exec cameleer-postgres pg_dump -U cameleer cameleer_saas > backup.sql docker compose -p $($c.ComposeProject) exec cameleer-clickhouse clickhouse-client --query "SELECT * FROM cameleer.traces FORMAT Native" > traces.native `````` ## Upgrading ``````powershell .\install.ps1 -InstallDir $($c.InstallDir) -Version NEW_VERSION `````` ## Troubleshooting | Issue | Command | |---|---| | Service not starting | ``docker compose -p $($c.ComposeProject) logs SERVICE_NAME`` | | Bootstrap failed | ``docker compose -p $($c.ComposeProject) logs cameleer-logto`` | | Routing issues | ``docker compose -p $($c.ComposeProject) logs cameleer-traefik`` | | Database issues | ``docker compose -p $($c.ComposeProject) exec cameleer-postgres psql -U cameleer -d cameleer_saas`` | ## Uninstalling ``````powershell Set-Location $($c.InstallDir) docker compose -p $($c.ComposeProject) down docker compose -p $($c.ComposeProject) down -v Remove-Item -Recurse -Force $($c.InstallDir) `````` "@ Write-Utf8File $f $txt Log-Info 'Generated INSTALL.md' } function Generate-InstallDocStandalone { $c = $script:cfg $f = Join-Path $c.InstallDir 'INSTALL.md' $ts = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' UTC' if ($c.TlsMode -eq 'custom') { $tlsDesc = 'Custom certificate' } else { $tlsDesc = 'Self-signed (auto-generated)' } if ($c.MonitoringNetwork) { $monSection = @" ### Monitoring Services are connected to the ``$($c.MonitoringNetwork)`` Docker network for Prometheus auto-discovery. "@ } else { $monSection = '' } if ($c.TlsMode -eq 'self-signed') { $tlsSection = @' The platform generated a self-signed certificate on first boot. Replace it by placing your certificate and key files in the ``certs/`` directory and restarting. '@ } else { $tlsSection = '' } $txt = @" # Cameleer Server -- Installation Documentation ## Installation Summary | | | |---|---| | **Version** | $($c.Version) | | **Date** | $ts | | **Installer** | v${CAMELEER_INSTALLER_VERSION} | | **Mode** | Standalone (single-tenant) | | **Install Directory** | $($c.InstallDir) | | **Hostname** | $($c.PublicHost) | | **TLS** | $tlsDesc | ## Service URLs - **Server Dashboard:** $($c.PublicProtocol)://$($c.PublicHost)/ - **API Endpoint:** $($c.PublicProtocol)://$($c.PublicHost)/api/ ## First Steps 1. Open the Server Dashboard in your browser 2. Log in with the admin credentials from ``credentials.txt`` 3. Upload a Camel application JAR to deploy your first route 4. Monitor traces, metrics, and logs in the dashboard ## Architecture | Container | Purpose | |---|---| | ``cameleer-traefik`` | Reverse proxy, TLS termination, routing | | ``cameleer-postgres`` | PostgreSQL database (server data) | | ``cameleer-clickhouse`` | Time-series storage (traces, metrics, logs) | | ``cameleer-server`` | Cameleer Server (Spring Boot backend) | | ``cameleer-server-ui`` | Cameleer Dashboard (React frontend) | ## Networking | Port | Service | |---|---| | $($c.HttpPort) | HTTP (redirects to HTTPS) | | $($c.HttpsPort) | HTTPS (main entry point) | $monSection ## TLS **Mode:** $tlsDesc $tlsSection ## Data & Backups | Docker Volume | Contains | |---|---| | ``cameleer-pgdata`` | PostgreSQL data (server config, routes, deployments) | | ``cameleer-chdata`` | ClickHouse data (traces, metrics, logs) | | ``cameleer-certs`` | TLS certificates | | ``jars`` | Uploaded application JARs | ### Backup Commands ``````bash docker compose -p $($c.ComposeProject) exec cameleer-postgres pg_dump -U cameleer cameleer > backup.sql docker compose -p $($c.ComposeProject) exec cameleer-clickhouse clickhouse-client --query "SELECT * FROM cameleer.traces FORMAT Native" > traces.native `````` ## Upgrading ``````powershell .\install.ps1 -InstallDir $($c.InstallDir) -Version NEW_VERSION `````` ## Troubleshooting | Issue | Command | |---|---| | Service not starting | ``docker compose -p $($c.ComposeProject) logs SERVICE_NAME`` | | Server issues | ``docker compose -p $($c.ComposeProject) logs cameleer-server`` | | Routing issues | ``docker compose -p $($c.ComposeProject) logs cameleer-traefik`` | | Database issues | ``docker compose -p $($c.ComposeProject) exec cameleer-postgres psql -U cameleer -d cameleer`` | ## Uninstalling ``````powershell Set-Location $($c.InstallDir) docker compose -p $($c.ComposeProject) down docker compose -p $($c.ComposeProject) down -v Remove-Item -Recurse -Force $($c.InstallDir) `````` "@ Write-Utf8File $f $txt Log-Info 'Generated INSTALL.md' } # --- Console output --- function Print-Credentials { $c = $script:cfg Write-Host '' Write-Host '==========================================' -ForegroundColor Cyan if ($c.DeploymentMode -eq 'standalone') { Write-Host ' CAMELEER SERVER CREDENTIALS' -ForegroundColor Cyan } else { Write-Host ' CAMELEER PLATFORM CREDENTIALS' -ForegroundColor Cyan } Write-Host '==========================================' -ForegroundColor Cyan Write-Host '' if ($c.DeploymentMode -eq 'standalone') { Write-Host ' Dashboard: ' -NoNewline Write-Host "$($c.PublicProtocol)://$($c.PublicHost)/" -ForegroundColor Blue } else { Write-Host ' Admin Console: ' -NoNewline Write-Host "$($c.PublicProtocol)://$($c.PublicHost)/platform/" -ForegroundColor Blue } Write-Host " Admin User: $($c.AdminUser)" Write-Host " Admin Password: $($c.AdminPass)" Write-Host '' Write-Host " PostgreSQL: cameleer / $($c.PostgresPassword)" Write-Host " ClickHouse: default / $($c.ClickhousePassword)" Write-Host '' if ($c.DeploymentMode -eq 'saas' -and $c.LogtoConsoleExposed -eq 'true') { Write-Host ' Logto Console: ' -NoNewline Write-Host "$($c.PublicProtocol)://$($c.AuthHost):$($c.LogtoConsolePort)" -ForegroundColor Blue Write-Host '' } Write-Host " Credentials saved to: $($c.InstallDir)\credentials.txt" Write-Host ' Secure this file and delete after noting credentials.' -ForegroundColor Yellow Write-Host '' } function Print-Summary { $c = $script:cfg Write-Host '==========================================' -ForegroundColor Green Write-Host ' Installation complete!' -ForegroundColor Green Write-Host '==========================================' -ForegroundColor Green Write-Host '' Write-Host " Install directory: $($c.InstallDir)" Write-Host " Documentation: $($c.InstallDir)\INSTALL.md" Write-Host '' Write-Host ' To manage the stack:' Write-Host " Set-Location $($c.InstallDir)" Write-Host " docker compose -p $($c.ComposeProject) ps # status" Write-Host " docker compose -p $($c.ComposeProject) logs -f # logs" Write-Host " docker compose -p $($c.ComposeProject) down # stop" Write-Host '' } # --- Re-run / upgrade --- function Show-RerunMenu { $c = $script:cfg $confFile = Join-Path $c.InstallDir 'cameleer.conf' $currentVersion = '' $currentHost = '' foreach ($line in (Get-Content $confFile -ErrorAction SilentlyContinue)) { 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 Cyan Write-Host " Install directory: $($c.InstallDir)" Write-Host " Public host: $currentHost" Write-Host '' if ($script:Mode -eq 'silent') { if (-not $script:RerunAction) { $script:RerunAction = 'upgrade' } return } if ($script:RerunAction) { return } $newVersion = Coalesce $c.Version $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) { '2' { $script:RerunAction = 'reconfigure' } '3' { $script:RerunAction = 'reinstall' } '4' { Write-Host 'Cancelled.'; exit 0 } default { $script:RerunAction = 'upgrade' } } } function Handle-Rerun { $c = $script:cfg switch ($script:RerunAction) { 'upgrade' { Log-Info 'Upgrading installation...' Load-ConfigFile (Join-Path $c.InstallDir 'cameleer.conf') Load-EnvOverrides Merge-Config $script:cfg.InstallDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath( $script:cfg.InstallDir) Copy-Templates Invoke-ComposePull Invoke-ComposeDown Invoke-ComposeUp Verify-Health Generate-InstallDoc Print-Summary exit 0 } 'reconfigure' { Log-Info 'Reconfiguring installation...' return } 'reinstall' { if (-not $script:ConfirmDestroy) { Write-Host '' Log-Warn 'This will destroy ALL data (databases, certificates, bootstrap).' if (-not (Prompt-YesNo 'Are you sure? This cannot be undone.')) { Write-Host 'Cancelled.' exit 0 } } Log-Info 'Reinstalling...' try { Invoke-ComposeDown } catch {} Push-Location $c.InstallDir try { $proj = Coalesce $c.ComposeProject 'cameleer-saas' docker compose -p $proj down -v 2>$null } catch {} finally { Pop-Location } foreach ($fname in @('.env','.env.bak','.env.example','docker-compose.yml','docker-compose.saas.yml','docker-compose.server.yml','docker-compose.tls.yml','docker-compose.monitoring.yml','traefik-dynamic.yml','cameleer.conf','credentials.txt','INSTALL.md')) { $fp = Join-Path $c.InstallDir $fname if (Test-Path $fp) { Remove-Item $fp -Force } } $certsDir = Join-Path $c.InstallDir 'certs' if (Test-Path $certsDir) { Remove-Item $certsDir -Recurse -Force } $script:IsRerun = $false } } } # --- Entry point --- function Main { if ($Help) { Show-Help; exit 0 } Print-Banner if ($Config) { Load-ConfigFile $Config } Load-EnvOverrides Detect-ExistingInstall if ($script:IsRerun) { Show-RerunMenu Handle-Rerun } Check-Prerequisites Auto-Detect if ($script:Mode -ne 'silent') { Select-Mode if ($script:Mode -eq 'expert') { Run-ExpertPrompts } else { Run-SimplePrompts } } Merge-Config Validate-Config Generate-Passwords # Resolve to absolute path NOW. # [System.IO.File]::WriteAllText uses .NET's Environment.CurrentDirectory, # which differs from PowerShell's $PWD on Windows (e.g. resolves ./cameleer # to C:\Users\Hendrik\cameleer instead of the script's working directory). # GetUnresolvedProviderPathFromPSPath always uses PowerShell's current location. $script:cfg.InstallDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath( $script:cfg.InstallDir) New-Item -ItemType Directory -Force -Path $script:cfg.InstallDir | Out-Null if ($script:cfg.TlsMode -eq 'custom') { Copy-Certs } Generate-EnvFile Copy-Templates Write-ConfigFile Invoke-ComposePull Invoke-ComposeUp Verify-Health Generate-CredentialsFile Generate-InstallDoc Print-Credentials Print-Summary } Main