From 528c6d1980c884123bddd92dcb5d2bbb72f05339 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:31:54 +0200 Subject: [PATCH] feat: restore PowerShell installer lost during submodule migration install.ps1 was deleted in the refactor that moved the installer to its own repo but was never copied over. Restored from cameleer-saas history with registry default updated to registry.cameleer.io. Co-Authored-By: Claude Opus 4.6 (1M context) --- install.ps1 | 1551 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1551 insertions(+) create mode 100644 install.ps1 diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..88db452 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,1551 @@ +#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, + [string]$Registry, + [string]$RegistryUser, + [string]$RegistryToken, + [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' +$DEFAULT_REGISTRY = 'registry.cameleer.io/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 +$_ENV_REGISTRY = $env:REGISTRY +$_ENV_REGISTRY_USER = $env:REGISTRY_USER +$_ENV_REGISTRY_TOKEN = $env:REGISTRY_TOKEN + +# --- 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 + Registry = $Registry + RegistryUser = $RegistryUser + RegistryToken = $RegistryToken +} + +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 'Registry options:' + Write-Host ' -Registry REGISTRY Image registry (default: registry.cameleer.io/cameleer)' + Write-Host ' -RegistryUser USER Registry username for docker login' + Write-Host ' -RegistryToken TOKEN Registry token/password for docker login' + 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 } } + 'registry' { if (-not $script:cfg.Registry) { $script:cfg.Registry = $val } } + 'registry_user' { if (-not $script:cfg.RegistryUser) { $script:cfg.RegistryUser = $val } } + 'registry_token' { if (-not $script:cfg.RegistryToken) { $script:cfg.RegistryToken = $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 } + if (-not $c.Registry) { $c.Registry = $_ENV_REGISTRY } + if (-not $c.RegistryUser) { $c.RegistryUser = $_ENV_REGISTRY_USER } + if (-not $c.RegistryToken) { $c.RegistryToken = $_ENV_REGISTRY_TOKEN } +} + +# --- 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 '' + if (Prompt-YesNo 'Pull images from a private registry?') { + $c.Registry = Prompt-Value 'Registry' (Coalesce $c.Registry $DEFAULT_REGISTRY) + $c.RegistryUser = Prompt-Value 'Registry username' (Coalesce $c.RegistryUser '') + $c.RegistryToken = Prompt-Password 'Registry token/password' (Coalesce $c.RegistryToken '') + } + + 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.Registry) { $c.Registry = $DEFAULT_REGISTRY } + + 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 + +# Registry +TRAEFIK_IMAGE=$($c.Registry)/cameleer-traefik +CLICKHOUSE_IMAGE=$($c.Registry)/cameleer-clickhouse +SERVER_IMAGE=$($c.Registry)/cameleer-server +SERVER_UI_IMAGE=$($c.Registry)/cameleer-server-ui +"@ + 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" } + } + $reg = $c.Registry + $provisioningBlock = @" + +# Docker +DOCKER_SOCKET=$($c.DockerSocket) +DOCKER_GID=$gid + +# Registry +TRAEFIK_IMAGE=$reg/cameleer-traefik +POSTGRES_IMAGE=$reg/cameleer-postgres +CLICKHOUSE_IMAGE=$reg/cameleer-clickhouse +LOGTO_IMAGE=$reg/cameleer-logto +CAMELEER_IMAGE=$reg/cameleer-saas + +# Provisioning images +CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=$reg/cameleer-server:$($c.Version) +CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=$reg/cameleer-server-ui:$($c.Version) +CAMELEER_SAAS_PROVISIONING_RUNTIMEBASEIMAGE=$reg/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-RegistryLogin { + $c = $script:cfg + if ($c.RegistryUser -and $c.RegistryToken) { + $registryHost = $c.Registry.Split('/')[0] + Log-Info "Logging in to registry ${registryHost}..." + $c.RegistryToken | docker login $registryHost -u $c.RegistryUser --password-stdin + if ($LASTEXITCODE -ne 0) { Log-Error 'Registry login failed.'; exit 1 } + Log-Success 'Registry login successful.' + } +} + +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, [string]$HostHeader = '') + $start = Get-Date + $lastDot = -1 + $headers = @{} + if ($HostHeader) { $headers['Host'] = $HostHeader } + 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 -Headers $headers -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 $c.PublicHost)) { $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) +registry=$($c.Registry) +registry_user=$($c.RegistryUser) +registry_token=$($c.RegistryToken) +"@ + 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-RegistryLogin + 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-RegistryLogin + Invoke-ComposePull + Invoke-ComposeUp + Verify-Health + + Generate-CredentialsFile + Generate-InstallDoc + + Print-Credentials + Print-Summary +} + +Main \ No newline at end of file