Files
cameleer-saas/installer/install.ps1

1627 lines
65 KiB
PowerShell
Raw Normal View History

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