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