diff --git a/installer/install.ps1 b/installer/install.ps1 index f34a8b3..b90315f 100644 --- a/installer/install.ps1 +++ b/installer/install.ps1 @@ -1,19 +1,15 @@ #Requires -Version 5.1 <# .SYNOPSIS - Cameleer SaaS Platform Installer for Windows + Cameleer SaaS Platform Installer .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. + Installs the Cameleer SaaS platform using Docker Compose. .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 + .\install.ps1 -Expert + .\install.ps1 -Silent -PublicHost myhost.example.com #> + [CmdletBinding()] param( [switch]$Silent, @@ -24,25 +20,22 @@ param( [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]$HttpPort, + [string]$HttpsPort, + [string]$LogtoConsolePort, [string]$LogtoConsoleExposed, - [string]$VendorEnabled, - [string]$VendorUser, - [string]$VendorPassword, [string]$MonitoringNetwork, [string]$Version, [string]$ComposeProject, [string]$DockerSocket, [string]$NodeTlsReject, + [string]$DeploymentMode, [switch]$Reconfigure, [switch]$Reinstall, [switch]$ConfirmDestroy, @@ -52,108 +45,97 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -# --------------------------------------------------------------------------- -# Constants and defaults -# --------------------------------------------------------------------------- +# --- Constants --- -$CAMELEER_INSTALLER_VERSION = '1.0.0' -$CAMELEER_DEFAULT_VERSION = 'latest' -$REGISTRY = 'gitea.siegeln.net/cameleer' +$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' +$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' -# --------------------------------------------------------------------------- -# 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 { '' } +# --- Capture env vars before any overrides --- -# --------------------------------------------------------------------------- -# 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 { '' } +$_ENV_PUBLIC_HOST = $env:PUBLIC_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 -# State -$script:MODE = '' # simple, expert, silent -$script:IS_RERUN = $false -$script:RERUN_ACTION = '' # upgrade, reconfigure, reinstall -$script:CONFIRM_DESTROY = $false +# --- Mutable config state --- -# 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 } +$script:cfg = @{ + InstallDir = $InstallDir + PublicHost = $PublicHost + 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 +} -# --------------------------------------------------------------------------- -# Utility functions -# --------------------------------------------------------------------------- +if ($Silent) { $script:Mode = 'silent' } +elseif ($Expert) { $script:Mode = 'expert' } +else { $script:Mode = '' } -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 } +if ($Reconfigure) { $script:RerunAction = 'reconfigure' } +elseif ($Reinstall) { $script:RerunAction = 'reinstall' } +else { $script:RerunAction = '' } -function Show-Banner { +$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 White - Write-Host ' / ___|__ _ _ __ ___ ___ | | ___ ___ _ __ ' -ForegroundColor White - Write-Host '| | / _` | `''_ ` _ \ / _ \| |/ _ \/ _ \ `''__|' -ForegroundColor White - Write-Host '| |__| (_| | | | | | | __/| | __/ __/ | ' -ForegroundColor White - Write-Host ' \____\__,_|_| |_| |_|\___||_|\___|\___||_| ' -ForegroundColor White + 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 White + Write-Host " SaaS Platform Installer v$CAMELEER_INSTALLER_VERSION" -ForegroundColor Cyan Write-Host '' } @@ -182,586 +164,505 @@ function Show-Help { 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' + Write-Host ' -Reinstall -ConfirmDestroy Fresh install (destroys data)' } - -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 + +# --- Helpers --- + +function Coalesce { + param($a, $b) + if ($a) { return $a } else { return $b } } - -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 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 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 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 Invoke-YesNoPrompt { - param( - [string]$PromptText, - [string]$Default = 'n' - ) + +function Prompt-YesNo { + param([string]$Text, [string]$Default = 'n') if ($Default -eq 'y') { - $input = Read-Host " $PromptText [Y/n]" - if ([string]::IsNullOrEmpty($input)) { return $true } - return ($input -match '^[yY]') + $inp = Read-Host " $Text [Y/n]" + if ($inp -match '^[nN]') { return $false } else { return $true } } else { - $input = Read-Host " $PromptText [y/N]" - if ([string]::IsNullOrEmpty($input)) { return $false } - return ($input -match '^[yY]') + $inp = Read-Host " $Text [y/N]" + return ($inp -match '^[yY]') } } - -# --------------------------------------------------------------------------- -# Config file loading -# --------------------------------------------------------------------------- - + +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 -PathType Leaf)) { return } - - foreach ($line in [System.IO.File]::ReadAllLines($FilePath)) { - # Skip comments and empty lines + if (-not (Test-Path $FilePath)) { return } + foreach ($line in Get-Content $FilePath) { 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 } } + 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 } } + '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 } } + } } } } - -# --------------------------------------------------------------------------- -# 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 } + $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.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 } } - -# --------------------------------------------------------------------------- -# Prerequisites -# --------------------------------------------------------------------------- - -function Test-PortAvailable { + +# --- Prerequisites --- + +function Check-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 - } + $hits = netstat -an 2>$null | Select-String ":${Port} " + if ($hits) { Log-Warn "Port $Port ($Name) appears to be in use." } + } catch {} } - -function Test-Prerequisites { - Write-Info 'Checking prerequisites...' + +function Check-Prerequisites { + Log-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.' + + 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 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." + + 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" } - - # 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" + + 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)) { - Write-Warn "Docker socket not found at $socket" + Log-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' - + + 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) { - Write-Err "$errors prerequisite(s) not met. Please install missing dependencies and retry." + Log-Error "$errors prerequisite(s) not met. Please install missing dependencies and retry." exit 1 } - Write-Success 'All prerequisites met.' + Log-Success 'All prerequisites met.' } - -# --------------------------------------------------------------------------- -# Auto-detection -# --------------------------------------------------------------------------- - -function Get-AutoDetectedDefaults { - if ($script:CFG_PUBLIC_HOST -eq '') { + +# --- Auto-detection --- + +function Auto-Detect { + if (-not $script:cfg.PublicHost) { try { - $entry = [System.Net.Dns]::GetHostEntry([System.Net.Dns]::GetHostName()) - $script:CFG_PUBLIC_HOST = $entry.HostName + $fqdn = [System.Net.Dns]::GetHostEntry([System.Net.Dns]::GetHostName()).HostName + $script:cfg.PublicHost = $fqdn.ToLower() } catch { - $script:CFG_PUBLIC_HOST = [System.Net.Dns]::GetHostName() + $script:cfg.PublicHost = [System.Net.Dns]::GetHostName().ToLower() } - if (-not $script:CFG_PUBLIC_HOST) { $script:CFG_PUBLIC_HOST = 'localhost' } } - - if ($script:CFG_DOCKER_SOCKET -eq '') { - $script:CFG_DOCKER_SOCKET = $DEFAULT_DOCKER_SOCKET + if (-not $script:cfg.DockerSocket) { + if ($env:OS -eq 'Windows_NT') { + $script:cfg.DockerSocket = '//./pipe/docker_engine' + } else { + $script:cfg.DockerSocket = $DEFAULT_DOCKER_SOCKET + } } } - -# --------------------------------------------------------------------------- -# Detect existing installation -# --------------------------------------------------------------------------- - -function Find-ExistingInstall { - $dir = if ($script:CFG_INSTALL_DIR -ne '') { $script:CFG_INSTALL_DIR } else { $DEFAULT_INSTALL_DIR } + +function Detect-ExistingInstall { + $dir = Coalesce $script:cfg.InstallDir $DEFAULT_INSTALL_DIR $confPath = Join-Path $dir 'cameleer.conf' - if (Test-Path $confPath -PathType Leaf) { - $script:IS_RERUN = $true - $script:CFG_INSTALL_DIR = $dir + if (Test-Path $confPath) { + $script:IsRerun = $true + $script:cfg.InstallDir = $dir Load-ConfigFile $confPath } } - -# --------------------------------------------------------------------------- -# Mode selection -# --------------------------------------------------------------------------- - -function Select-InstallMode { - if ($script:MODE -ne '') { return } + +# --- 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 ' [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' } + if ($choice -eq '2') { $script:Mode = 'expert' } else { $script:Mode = 'simple' } } - -# --------------------------------------------------------------------------- -# Interactive prompts -# --------------------------------------------------------------------------- - -function Invoke-SimplePrompts { + +function Run-SimplePrompts { + $c = $script:cfg Write-Host '' - Write-Host '--- Simple Installation ---' -ForegroundColor White + Write-Host '--- Simple Installation ---' -ForegroundColor Cyan 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 = '' + + $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 { - $script:CFG_ADMIN_PASS = Invoke-PasswordPrompt 'Admin password' + $c.AdminPass = Prompt-Password '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)' + 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 { - $script:CFG_TLS_MODE = 'self-signed' + $c.TlsMode = 'self-signed' } - + Write-Host '' - $script:CFG_MONITORING_NETWORK = Invoke-Prompt 'Monitoring network name (empty = skip)' + $c.MonitoringNetwork = Prompt-Value 'Monitoring network name (empty = skip)' '' + + Write-Host '' + Write-Host ' Deployment mode:' + Write-Host ' [1] Multi-tenant SaaS -- manage platform, provision tenants on demand' + Write-Host ' [2] Single-tenant -- one server instance, local auth, no identity provider' + Write-Host '' + $deployChoice = Read-Host ' Select mode [1]' + if ($deployChoice -eq '2') { $c.DeploymentMode = 'standalone' } else { $c.DeploymentMode = 'saas' } } - -function Invoke-ExpertPrompts { + +function Run-ExpertPrompts { + $c = $script:cfg + Run-SimplePrompts + 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 = '' + Write-Host ' Credentials:' -ForegroundColor Cyan + if (Prompt-YesNo 'Auto-generate database passwords?' 'y') { + $c.PostgresPassword = '' + $c.ClickhousePassword = '' } else { - $script:CFG_POSTGRES_PASSWORD = Invoke-PasswordPrompt 'PostgreSQL password' - $script:CFG_CLICKHOUSE_PASSWORD = Invoke-PasswordPrompt 'ClickHouse password' + $c.PostgresPassword = Prompt-Password 'PostgreSQL password' + $c.ClickhousePassword = Prompt-Password '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 = '' + 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 + if (Prompt-YesNo 'Expose Logto admin console externally?' 'y') { + $c.LogtoConsoleExposed = 'true' } else { - $script:CFG_VENDOR_PASS = Invoke-PasswordPrompt 'Vendor password' + $c.LogtoConsoleExposed = 'false' } - } 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 -# --------------------------------------------------------------------------- - + +# --- Config merge & 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' + $c = $script:cfg + if (-not $c.DeploymentMode) { $c.DeploymentMode = 'saas' } + if (-not $c.InstallDir) { $c.InstallDir = $DEFAULT_INSTALL_DIR } + if (-not $c.PublicHost) { $c.PublicHost = 'localhost' } + if (-not $c.PublicProtocol) { $c.PublicProtocol = $DEFAULT_PUBLIC_PROTOCOL } + if (-not $c.AdminUser) { $c.AdminUser = $DEFAULT_ADMIN_USER } + if (-not $c.TlsMode) { $c.TlsMode = $DEFAULT_TLS_MODE } + if (-not $c.HttpPort) { $c.HttpPort = $DEFAULT_HTTP_PORT } + if (-not $c.HttpsPort) { $c.HttpsPort = $DEFAULT_HTTPS_PORT } + if (-not $c.LogtoConsolePort) { $c.LogtoConsolePort = $DEFAULT_LOGTO_CONSOLE_PORT } + if (-not $c.LogtoConsoleExposed) { $c.LogtoConsoleExposed = $DEFAULT_LOGTO_CONSOLE_EXPOSED } + if (-not $c.Version) { $c.Version = $CAMELEER_DEFAULT_VERSION } + if (-not $c.DockerSocket) { $c.DockerSocket = $DEFAULT_DOCKER_SOCKET } + + if (-not $c.ComposeProject) { + if ($c.DeploymentMode -eq 'standalone') { + $c.ComposeProject = $DEFAULT_COMPOSE_PROJECT_STANDALONE } else { - $script:CFG_NODE_TLS_REJECT = '0' + $c.ComposeProject = $DEFAULT_COMPOSE_PROJECT } } + + # Force lowercase -- Logto normalises internally; case mismatch breaks JWT validation + $c.PublicHost = $c.PublicHost.ToLower() + + if ($c.DeploymentMode -ne 'standalone' -and (-not $c.NodeTlsReject)) { + if ($c.TlsMode -eq 'custom') { $c.NodeTlsReject = '1' } else { $c.NodeTlsReject = '0' } + } } - -function Test-Config { + +function Validate-Config { + $c = $script:cfg $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++ + + 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++ } } - - 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 ($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) { - Write-Err 'Configuration validation failed.' - exit 1 - } - Write-Success 'Configuration validated.' + + if ($errors -gt 0) { Log-Error 'Configuration validation failed.'; exit 1 } + Log-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.' - } + +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.' } } - -# --------------------------------------------------------------------------- -# 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 + +# --- 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 {} } - Write-Info "Copied TLS certificates to $certsDir/" + return '0' } - -# --------------------------------------------------------------------------- -# 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 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" } - -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} - + +# --- .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 + + 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=${pgPass} -POSTGRES_DB=cameleer_saas - +POSTGRES_PASSWORD=$($c.PostgresPassword) +POSTGRES_DB=cameleer3 + # 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} - +CLICKHOUSE_PASSWORD=$($c.ClickhousePassword) + +# Server admin +SERVER_ADMIN_USER=$($c.AdminUser) +SERVER_ADMIN_PASS=$($c.AdminPass) + +# Bootstrap token +BOOTSTRAP_TOKEN=$bt + # Docker -DOCKER_SOCKET=${dockerSock} - -# Provisioning images -CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=${REGISTRY}/cameleer3-server:${ver} -CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=${REGISTRY}/cameleer3-server-ui:${ver} +DOCKER_SOCKET=$($c.DockerSocket) +DOCKER_GID=$gid "@ - Append-LFFile $f $vendorSection - - Write-Info 'Generated .env' - Copy-Item $f (Join-Path $script:CFG_INSTALL_DIR '.env.bak') -Force + 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" } + } + } else { + $content = @" +# Cameleer SaaS Configuration +# 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) +LOGTO_CONSOLE_PORT=$($c.LogtoConsolePort) + +# PostgreSQL +POSTGRES_USER=cameleer +POSTGRES_PASSWORD=$($c.PostgresPassword) +POSTGRES_DB=cameleer_saas + +# ClickHouse +CLICKHOUSE_PASSWORD=$($c.ClickhousePassword) + +# Admin user +SAAS_ADMIN_USER=$($c.AdminUser) +SAAS_ADMIN_PASS=$($c.AdminPass) + +# TLS +NODE_TLS_REJECT=$($c.NodeTlsReject) +"@ + if ($c.TlsMode -eq 'custom') { + $content += "`nCERT_FILE=/user-certs/cert.pem" + $content += "`nKEY_FILE=/user-certs/key.pem" + if ($c.CaFile) { $content += "`nCA_FILE=/user-certs/ca.pem" } + } + $provisioningBlock = @" + +# Docker +DOCKER_SOCKET=$($c.DockerSocket) +DOCKER_GID=$gid + +# Provisioning images +CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=${REGISTRY}/cameleer3-server:$($c.Version) +CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=${REGISTRY}/cameleer3-server-ui:$($c.Version) +"@ + $content += $provisioningBlock + } + + Write-Utf8File $f $content + Copy-Item $f (Join-Path $c.InstallDir '.env.bak') -Force + Log-Info 'Generated .env' } - -# --------------------------------------------------------------------------- -# 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 = @' + +# --- Docker Compose generation --- +# Rule: '@ and "@ closing delimiters must ALWAYS be alone at column 0 on their own line. + +function Generate-ComposeFile { + $c = $script:cfg + if ($c.DeploymentMode -eq 'standalone') { Generate-ComposeFileStandalone; return } + + $f = Join-Path $c.InstallDir 'docker-compose.yml' + $gid = Get-DockerGid $c.DockerSocket + $out = New-Object System.Collections.Generic.List[string] + + $out.Add(@' # Cameleer SaaS Platform -# Generated by Cameleer installer — do not edit manually - +# Generated by Cameleer installer -- do not edit manually + services: cameleer-traefik: image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest} @@ -770,16 +671,11 @@ services: - "${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" -'@ + ) + if ($c.LogtoConsoleExposed -eq 'true') { + $out.Add(' - "${LOGTO_CONSOLE_PORT:-3002}:3002"') } - - Append-LFFile $f @' + $out.Add(@' environment: PUBLIC_HOST: ${PUBLIC_HOST:-localhost} CERT_FILE: ${CERT_FILE:-} @@ -789,34 +685,27 @@ services: - cameleer-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 @' + ) + if ($c.TlsMode -eq 'custom') { $out.Add(' - ./certs:/user-certs:ro') } + $out.Add(@' 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 @' + ) + if ($c.MonitoringNetwork) { + $out.Add(" - $($c.MonitoringNetwork)") + $out.Add(@' labels: - "prometheus.io/scrape=true" - "prometheus.io/port=8082" - "prometheus.io/path=/metrics" '@ + ) } - - # --- postgres service --- - Append-LFFile $f @' - + + $out.Add(@' + cameleer-postgres: image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest} restart: unless-stopped @@ -834,14 +723,11 @@ services: networks: - cameleer '@ - - if ($script:CFG_MONITORING_NETWORK -ne '') { - Append-LFFile $f " - $($script:CFG_MONITORING_NETWORK)`n" - } - - # --- clickhouse service --- - Append-LFFile $f @' - + ) + if ($c.MonitoringNetwork) { $out.Add(" - $($c.MonitoringNetwork)") } + + $out.Add(@' + cameleer-clickhouse: image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest} restart: unless-stopped @@ -857,20 +743,20 @@ services: networks: - cameleer '@ - - if ($script:CFG_MONITORING_NETWORK -ne '') { - Append-LFFile $f " - $($script:CFG_MONITORING_NETWORK)`n" - Append-LFFile $f @' + ) + if ($c.MonitoringNetwork) { + $out.Add(" - $($c.MonitoringNetwork)") + $out.Add(@' labels: - "prometheus.io/scrape=true" - "prometheus.io/port=9363" - "prometheus.io/path=/metrics" '@ + ) } - - # --- logto service --- - Append-LFFile $f @' - + + $out.Add(@' + cameleer-logto: image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest} restart: unless-stopped @@ -894,9 +780,6 @@ services: 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 @@ -917,24 +800,24 @@ services: - traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true - traefik.http.services.cameleer-logto.loadbalancer.server.port=3001 '@ - - # Conditional: Logto console router labels - if ($script:CFG_LOGTO_CONSOLE_EXPOSED -eq 'true') { - Append-LFFile $f @' + ) + if ($c.LogtoConsoleExposed -eq 'true') { + $out.Add(@' - traefik.http.routers.cameleer-logto-console.rule=PathPrefix(`/`) - traefik.http.routers.cameleer-logto-console.entrypoints=admin-console - traefik.http.routers.cameleer-logto-console.tls=true - traefik.http.routers.cameleer-logto-console.service=cameleer-logto-console - traefik.http.services.cameleer-logto-console.loadbalancer.server.port=3002 '@ + ) } - - Append-LFFile $f @' + + $out.Add(@' volumes: - cameleer-bootstrapdata:/data networks: - cameleer - + cameleer-saas: image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest} restart: unless-stopped @@ -947,10 +830,13 @@ services: SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: http://cameleer-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_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https} CAMELEER_SAAS_PROVISIONING_NETWORKNAME: ${COMPOSE_PROJECT_NAME:-cameleer-saas}_cameleer CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK: cameleer-traefik + CAMELEER_SAAS_PROVISIONING_DATASOURCEUSERNAME: ${POSTGRES_USER:-cameleer} + CAMELEER_SAAS_PROVISIONING_DATASOURCEPASSWORD: ${POSTGRES_PASSWORD} + CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD: ${CLICKHOUSE_PASSWORD} 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: @@ -960,17 +846,16 @@ services: - 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 @' + ) + if ($c.MonitoringNetwork) { + $out.Add(@' - "prometheus.io/scrape=true" - "prometheus.io/port=8080" - "prometheus.io/path=/platform/actuator/prometheus" '@ + ) } - - Append-LFFile $f @' + $out.Add(@' volumes: - cameleer-bootstrapdata:/data/bootstrap:ro - cameleer-certs:/certs @@ -978,21 +863,19 @@ services: networks: - cameleer '@ - - if ($script:CFG_MONITORING_NETWORK -ne '') { - Append-LFFile $f " - $($script:CFG_MONITORING_NETWORK)`n" - } - - Append-LFFile $f @' - group_add: - - "0" - + ) + if ($c.MonitoringNetwork) { $out.Add(" - $($c.MonitoringNetwork)") } + $out.Add(" group_add:") + $out.Add(" - `"$gid`"") + + $out.Add(@' + volumes: cameleer-pgdata: cameleer-chdata: cameleer-certs: cameleer-bootstrapdata: - + networks: cameleer: driver: bridge @@ -1000,627 +883,913 @@ networks: 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 + if ($c.MonitoringNetwork) { + $out.Add(" $($c.MonitoringNetwork):") + $out.Add(' external: true') + } + + Write-Utf8File $f ($out -join "`n") + Log-Info 'Generated docker-compose.yml' +} + +function Generate-ComposeFileStandalone { + $c = $script:cfg + $f = Join-Path $c.InstallDir 'docker-compose.yml' + $gid = Get-DockerGid $c.DockerSocket + $out = New-Object System.Collections.Generic.List[string] + + $out.Add(@' +# Cameleer Server (standalone) +# Generated by Cameleer installer -- do not edit manually + +services: + cameleer-traefik: + image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest} + restart: unless-stopped + ports: + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" + environment: + PUBLIC_HOST: ${PUBLIC_HOST:-localhost} + CERT_FILE: ${CERT_FILE:-} + KEY_FILE: ${KEY_FILE:-} + CA_FILE: ${CA_FILE:-} + volumes: + - cameleer-certs:/certs + - ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro + - ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro +'@ + ) + if ($c.TlsMode -eq 'custom') { $out.Add(' - ./certs:/user-certs:ro') } + $out.Add(@' + networks: + - cameleer + - cameleer-traefik +'@ + ) + if ($c.MonitoringNetwork) { $out.Add(" - $($c.MonitoringNetwork)") } + + $out.Add(@' + + cameleer-postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-cameleer3} + POSTGRES_USER: ${POSTGRES_USER:-cameleer} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - cameleer-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d $${POSTGRES_DB:-cameleer3}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - cameleer +'@ + ) + if ($c.MonitoringNetwork) { $out.Add(" - $($c.MonitoringNetwork)") } + + $out.Add(@' + + cameleer-clickhouse: + image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest} + restart: unless-stopped + environment: + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} + volumes: + - cameleer-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 ($c.MonitoringNetwork) { $out.Add(" - $($c.MonitoringNetwork)") } + + # Server block: double-quoted so $gid expands; compose ${VAR} uses backtick-dollar + $serverBlock = @" + + cameleer-server: + image: `${SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer3-server}:`${VERSION:-latest} + container_name: cameleer-server + restart: unless-stopped + depends_on: + cameleer-postgres: + condition: service_healthy + environment: + CAMELEER_SERVER_TENANT_ID: default + SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/`${POSTGRES_DB:-cameleer3}?currentSchema=tenant_default + SPRING_DATASOURCE_USERNAME: `${POSTGRES_USER:-cameleer} + SPRING_DATASOURCE_PASSWORD: `${POSTGRES_PASSWORD} + CAMELEER_SERVER_CLICKHOUSE_URL: jdbc:clickhouse://cameleer-clickhouse:8123/cameleer + CAMELEER_SERVER_CLICKHOUSE_USERNAME: default + CAMELEER_SERVER_CLICKHOUSE_PASSWORD: `${CLICKHOUSE_PASSWORD} + CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN: `${BOOTSTRAP_TOKEN} + CAMELEER_SERVER_SECURITY_UIUSER: `${SERVER_ADMIN_USER:-admin} + CAMELEER_SERVER_SECURITY_UIPASSWORD: `${SERVER_ADMIN_PASS:-admin} + CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS: `${PUBLIC_PROTOCOL:-https}://`${PUBLIC_HOST:-localhost} + CAMELEER_SERVER_RUNTIME_ENABLED: "true" + CAMELEER_SERVER_RUNTIME_SERVERURL: http://cameleer-server:8081 + CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN: `${PUBLIC_HOST:-localhost} + CAMELEER_SERVER_RUNTIME_ROUTINGMODE: path + CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH: /data/jars + CAMELEER_SERVER_RUNTIME_DOCKERNETWORK: cameleer-apps + CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME: cameleer-jars + CAMELEER_SERVER_RUNTIME_BASEIMAGE: gitea.siegeln.net/cameleer/cameleer-runtime-base:`${VERSION:-latest} + labels: + - traefik.enable=true + - traefik.http.routers.server-api.rule=PathPrefix(``/api``) + - traefik.http.routers.server-api.entrypoints=websecure + - traefik.http.routers.server-api.tls=true + - traefik.http.services.server-api.loadbalancer.server.port=8081 + - traefik.docker.network=cameleer-traefik + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8081/api/v1/health || exit 1"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + volumes: + - jars:/data/jars + - cameleer-certs:/certs:ro + - `${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock + group_add: + - "$gid" + networks: + - cameleer + - cameleer-traefik + - cameleer-apps + + cameleer-server-ui: + image: `${SERVER_UI_IMAGE:-gitea.siegeln.net/cameleer/cameleer3-server-ui}:`${VERSION:-latest} + restart: unless-stopped + depends_on: + cameleer-server: + condition: service_healthy + environment: + CAMELEER_API_URL: http://cameleer-server:8081 + BASE_PATH: "" + labels: + - traefik.enable=true + - traefik.http.routers.ui.rule=PathPrefix(``/``) + - traefik.http.routers.ui.priority=1 + - traefik.http.routers.ui.entrypoints=websecure + - traefik.http.routers.ui.tls=true + - traefik.http.services.ui.loadbalancer.server.port=80 + - traefik.docker.network=cameleer-traefik + networks: + - cameleer-traefik +"@ + $out.Add($serverBlock) + + $out.Add(@' + +volumes: + cameleer-pgdata: + cameleer-chdata: + cameleer-certs: + jars: + +networks: + cameleer: + driver: bridge + cameleer-traefik: + name: cameleer-traefik + driver: bridge + cameleer-apps: + name: cameleer-apps + driver: bridge +'@ + ) + if ($c.MonitoringNetwork) { + $out.Add(" $($c.MonitoringNetwork):") + $out.Add(' external: true') + } + + Write-Utf8File $f ($out -join "`n") + + $traefikDyn = @' +tls: + stores: + default: + defaultCertificate: + certFile: /certs/cert.pem + keyFile: /certs/key.pem +'@ + Write-Utf8File (Join-Path $c.InstallDir 'traefik-dynamic.yml') $traefikDyn + + Log-Info 'Generated docker-compose.yml (standalone)' +} + +# --- Docker operations --- + +function Invoke-ComposePull { + $c = $script:cfg + Log-Info 'Pulling Docker images...' + Push-Location $c.InstallDir + try { docker compose -p $c.ComposeProject pull } + finally { Pop-Location } + Log-Success 'All images pulled.' +} + +function Invoke-ComposeUp { + $c = $script:cfg + Log-Info 'Starting Cameleer platform...' + Push-Location $c.InstallDir + try { docker compose -p $c.ComposeProject up -d } + 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) { - 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 + $elapsed = [int]((Get-Date) - $start).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" + 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 } - Start-Sleep -Seconds 5 + + # 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 Invoke-HealthVerification { + +function Test-Endpoint { + param([string]$Name, [string]$Url, [int]$TimeoutSecs = 120) + $start = Get-Date + $lastDot = -1 + while ($true) { + $elapsed = [int]((Get-Date) - $start).TotalSeconds + if ($elapsed -ge $TimeoutSecs) { + Write-Host (" [FAIL] {0,-20} not reachable after {1}s" -f $Name, $TimeoutSecs) -ForegroundColor Red + return $false + } + try { + # -SkipCertificateCheck is PS6+ only; SSL trust is handled by Enable-TrustAllCerts above + $resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop + $dur = [int]((Get-Date) - $start).TotalSeconds + Write-Host (" [ok] {0,-20} ready ({1}s)" -f $Name, $dur) -ForegroundColor Green + return $true + } catch { + if ($elapsed -gt 0 -and ($elapsed % 15) -lt 3 -and $elapsed -ne $lastDot) { + $lastDot = $elapsed + Write-Host (" [wait] {0,-20} {1}s elapsed" -f $Name, $elapsed) + } + Start-Sleep 3 + } + } +} + +function Verify-Health { + $c = $script:cfg Write-Host '' - Write-Info 'Verifying installation...' + Log-Info 'Verifying installation...' + Enable-TrustAllCerts # allow self-signed certs for PS5.1 Invoke-WebRequest $failed = $false - $proj = $script:CFG_COMPOSE_PROJECT - $dir = $script:CFG_INSTALL_DIR - $httpsPort = $script:CFG_HTTPS_PORT - + + if (-not (Wait-DockerHealthy 'PostgreSQL' 'cameleer-postgres' 120)) { $failed = $true } 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 (Wait-DockerHealthy 'ClickHouse' 'cameleer-clickhouse' 120)) { $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 ($c.DeploymentMode -eq 'standalone') { + if (-not $failed) { + if (-not (Wait-DockerHealthy 'Cameleer Server' 'cameleer-server' 300)) { $failed = $true } + } + if (-not $failed) { + if (-not (Test-Endpoint 'Server UI' "https://localhost:$($c.HttpsPort)/" 60)) { $failed = $true } + } + } else { + if (-not $failed) { + if (-not (Wait-DockerHealthy 'Logto + Bootstrap' 'cameleer-logto' 300)) { $failed = $true } + } + if (-not $failed) { + if (-not (Test-Endpoint 'Cameleer SaaS' "https://localhost:$($c.HttpsPort)/platform/api/config" 120)) { $failed = $true } + } + if (-not $failed) { + if (-not (Test-Endpoint 'Traefik routing' "https://localhost:$($c.HttpsPort)/" 30)) { $failed = $true } + } } - - 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.' + if ($failed) { Log-Error 'Installation verification failed. Stack is running -- check logs.'; exit 1 } + Log-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 = @" + +# --- 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) +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) +"@ + 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 PLATFORM CREDENTIALS - Generated: ${utcNow} - + CAMELEER SERVER CREDENTIALS + Generated: $ts + 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) - + +Server Dashboard: $($c.PublicProtocol)://$($c.PublicHost)/ +Admin User: $($c.AdminUser) +Admin Password: $($c.AdminPass) + +PostgreSQL: cameleer / $($c.PostgresPassword) +ClickHouse: default / $($c.ClickhousePassword) "@ - - 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 ($c.LogtoConsoleExposed -eq 'true') { + $logtoLine = "Logto Console: $($c.PublicProtocol)://$($c.PublicHost):$($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 +"@ } - - 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) + + Write-Utf8File $f $txt + + # Restrict permissions on Windows (best effort) try { - $acl = Get-Acl $f + $acl = Get-Acl $f $acl.SetAccessRuleProtection($true, $false) - $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( - [System.Security.Principal.WindowsIdentity]::GetCurrent().Name, + $rule = New-Object Security.AccessControl.FileSystemAccessRule( + [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' + } catch {} + + Log-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/ + +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.PublicHost):$($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. "@ - - Write-LFFile $f $content - - if ($script:CFG_LOGTO_CONSOLE_EXPOSED -eq 'true') { - Append-LFFile $f "- **Logto Admin Console:** ${proto}://${host_}:${logtoPort}`n" + } else { + $monSection = '' } - - 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 @' - + + 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 vendor console +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 = '' } - - Append-LFFile $f @" - + + $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 | +|---|---| +| ``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. + +## 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 | - +| ``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 -# PostgreSQL -docker compose -p ${proj} exec cameleer-postgres pg_dump -U cameleer cameleer_saas > backup.sql - -# ClickHouse -docker compose -p ${proj} exec cameleer-clickhouse clickhouse-client --query "SELECT * FROM cameleer.traces FORMAT Native" > traces.native -\`\`\` - + +``````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 - -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. - + +``````powershell +.\install.ps1 -InstallDir $($c.InstallDir) -Version NEW_VERSION +`````` + ## Troubleshooting - + | Issue | Command | |---|---| -| Service not starting | \`docker compose -p ${proj} logs SERVICE_NAME\` | -| Bootstrap failed | \`docker compose -p ${proj} logs cameleer-logto\` | -| Routing issues | \`docker compose -p ${proj} logs cameleer-traefik\` | -| Database issues | \`docker compose -p ${proj} exec cameleer-postgres psql -U cameleer -d cameleer_saas\` | - +| 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 - -\`\`\`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} -\`\`\` + +``````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-Info 'Generated INSTALL.md' + Write-Utf8File $f $txt + Log-Info 'Generated INSTALL.md' } - -# --------------------------------------------------------------------------- -# Print credentials to console -# --------------------------------------------------------------------------- - -function Show-Credentials { + +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 | +|---|---| +| ``traefik`` | Reverse proxy, TLS termination, routing | +| ``postgres`` | PostgreSQL database (server data) | +| ``clickhouse`` | Time-series storage (traces, metrics, logs) | +| ``server`` | Cameleer Server (Spring Boot backend) | +| ``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 cameleer3 > 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 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 cameleer3`` | + +## 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 White - Write-Host ' CAMELEER PLATFORM CREDENTIALS' -ForegroundColor White - Write-Host '==========================================' -ForegroundColor White + 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 '' - 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 + 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 / $($script:CFG_POSTGRES_PASSWORD)" - Write-Host " ClickHouse: default / $($script:CFG_CLICKHOUSE_PASSWORD)" + Write-Host " PostgreSQL: cameleer / $($c.PostgresPassword)" + Write-Host " ClickHouse: default / $($c.ClickhousePassword)" 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 + if ($c.DeploymentMode -eq 'saas' -and $c.LogtoConsoleExposed -eq 'true') { + Write-Host ' Logto Console: ' -NoNewline + Write-Host "$($c.PublicProtocol)://$($c.PublicHost):$($c.LogtoConsolePort)" -ForegroundColor Blue 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 " Credentials saved to: $($c.InstallDir)\credentials.txt" Write-Host ' Secure this file and delete after noting credentials.' -ForegroundColor Yellow Write-Host '' } - -# --------------------------------------------------------------------------- -# Summary -# --------------------------------------------------------------------------- - -function Show-Summary { + +function Print-Summary { + $c = $script:cfg Write-Host '==========================================' -ForegroundColor Green - Write-Host ' Installation complete!' -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 " Install directory: $($c.InstallDir)" + Write-Host " Documentation: $($c.InstallDir)\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 " 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 menu -# --------------------------------------------------------------------------- - + +# --- Re-run / upgrade --- + 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] } - } + $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 White - Write-Host " Install directory: $($script:CFG_INSTALL_DIR)" + 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 ($script:RERUN_ACTION -eq '') { $script:RERUN_ACTION = 'upgrade' } + + if ($script:Mode -eq 'silent') { + if (-not $script:RerunAction) { $script:RerunAction = '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)" + 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) { - '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 } + '2' { $script:RerunAction = 'reconfigure' } + '3' { $script:RerunAction = 'reinstall' } + '4' { Write-Host 'Cancelled.'; exit 0 } + default { $script:RerunAction = 'upgrade' } } } - -function Invoke-Rerun { - switch ($script:RERUN_ACTION) { + +function Handle-Rerun { + $c = $script:cfg + switch ($script:RerunAction) { 'upgrade' { - Write-Info 'Upgrading installation...' - Load-ConfigFile (Join-Path $script:CFG_INSTALL_DIR 'cameleer.conf') + Log-Info 'Upgrading installation...' + Load-ConfigFile (Join-Path $c.InstallDir 'cameleer.conf') Load-EnvOverrides Merge-Config - New-ComposeFile - Invoke-DockerPull - Invoke-DockerDown - Invoke-DockerUp - Invoke-HealthVerification - New-InstallDoc - Show-Summary + $script:cfg.InstallDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath( + $script:cfg.InstallDir) + Generate-ComposeFile + Invoke-ComposePull + Invoke-ComposeDown + Invoke-ComposeUp + Verify-Health + Generate-InstallDoc + Print-Summary exit 0 } 'reconfigure' { - Write-Info 'Reconfiguring installation...' + Log-Info 'Reconfiguring installation...' return } 'reinstall' { - if (-not $script:CONFIRM_DESTROY) { + if (-not $script:ConfirmDestroy) { Write-Host '' - Write-Warn 'This will destroy ALL data (databases, certificates, bootstrap).' - if (-not (Invoke-YesNoPrompt 'Are you sure? This cannot be undone.')) { + 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 } } - Write-Info 'Reinstalling...' - try { Invoke-DockerDown } catch { } + Log-Info 'Reinstalling...' + try { Invoke-ComposeDown } catch {} try { - Push-Location $script:CFG_INSTALL_DIR - & docker compose -p $script:CFG_COMPOSE_PROJECT down -v 2>$null + Push-Location $c.InstallDir + $proj = Coalesce $c.ComposeProject 'cameleer-saas' + docker compose -p $proj 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 + } catch {} + foreach ($fname in @('.env','.env.bak','docker-compose.yml','cameleer.conf','credentials.txt','INSTALL.md','traefik-dynamic.yml')) { + $fp = Join-Path $c.InstallDir $fname 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 + $certsDir = Join-Path $c.InstallDir 'certs' + if (Test-Path $certsDir) { Remove-Item $certsDir -Recurse -Force } + $script:IsRerun = $false } } } - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -function Invoke-Main { + +# --- Entry point --- + +function Main { if ($Help) { Show-Help; exit 0 } - - Show-Banner - - # Load config sources (CLI already applied via param block) - if ($Config -ne '') { - Load-ConfigFile $Config - } + + Print-Banner + + if ($Config) { Load-ConfigFile $Config } Load-EnvOverrides - - # Check for existing installation - Find-ExistingInstall - if ($script:IS_RERUN) { + + Detect-ExistingInstall + if ($script:IsRerun) { Show-RerunMenu - Invoke-Rerun - # If we reach here, action was 'reconfigure' or 'reinstall' (continues below) + Handle-Rerun } - - # 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 - } + + Check-Prerequisites + Auto-Detect + + if ($script:Mode -ne 'silent') { + Select-Mode + if ($script:Mode -eq 'expert') { Run-ExpertPrompts } else { Run-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 + 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 + Generate-ComposeFile + Write-ConfigFile + + Invoke-ComposePull + Invoke-ComposeUp + Verify-Health + + Generate-CredentialsFile + Generate-InstallDoc + + Print-Credentials + Print-Summary } - -# Entry point -Invoke-Main + +Main \ No newline at end of file diff --git a/installer/install.sh b/installer/install.sh index ac01f88..a1455f8 100644 --- a/installer/install.sh +++ b/installer/install.sh @@ -1027,7 +1027,7 @@ COMPOSEEOF cat >> "$f" << COMPOSEEOF - server: + cameleer-server: image: \${SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer3-server}:\${VERSION:-latest} container_name: cameleer-server restart: unless-stopped @@ -1078,11 +1078,11 @@ COMPOSEEOF - cameleer-traefik - cameleer-apps - server-ui: + cameleer-server-ui: image: \${SERVER_UI_IMAGE:-gitea.siegeln.net/cameleer/cameleer3-server-ui}:\${VERSION:-latest} restart: unless-stopped depends_on: - server: + cameleer-server: condition: service_healthy environment: CAMELEER_API_URL: http://cameleer-server:8081 @@ -1214,20 +1214,20 @@ verify_health() { log_info "Verifying installation..." local failed=0 - wait_for_docker_healthy "PostgreSQL" "postgres" 120 || failed=1 + wait_for_docker_healthy "PostgreSQL" "cameleer-postgres" 120 || failed=1 [ $failed -eq 0 ] && \ - wait_for_docker_healthy "ClickHouse" "clickhouse" 120 || failed=1 + wait_for_docker_healthy "ClickHouse" "cameleer-clickhouse" 120 || failed=1 if [ "$DEPLOYMENT_MODE" = "standalone" ]; then [ $failed -eq 0 ] && \ - wait_for_docker_healthy "Cameleer Server" "server" 300 || failed=1 + wait_for_docker_healthy "Cameleer Server" "cameleer-server" 300 || failed=1 [ $failed -eq 0 ] && \ check_endpoint "Server UI" "https://localhost:${HTTPS_PORT}/" 60 || failed=1 else [ $failed -eq 0 ] && \ - wait_for_docker_healthy "Logto + Bootstrap" "logto" 300 || failed=1 + wait_for_docker_healthy "Logto + Bootstrap" "cameleer-logto" 300 || failed=1 [ $failed -eq 0 ] && \ check_endpoint "Cameleer SaaS" "https://localhost:${HTTPS_PORT}/platform/api/config" 120 || failed=1