Files
cameleer-saas/installer/install.ps1
2026-04-15 21:08:34 +02:00

1420 lines
52 KiB
PowerShell

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