diff --git a/docs/superpowers/plans/2026-04-13-install-script-plan.md b/docs/superpowers/plans/2026-04-13-install-script-plan.md new file mode 100644 index 0000000..3843fc3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-install-script-plan.md @@ -0,0 +1,2662 @@ +# Cameleer SaaS Install Script — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a professional installer (bash + PowerShell) for the Cameleer SaaS platform, preceded by consolidating all service init logic into Docker images to eliminate bind-mounted config files. + +**Architecture:** Two-phase approach — first consolidate 7 services into 5 by baking init scripts into custom Docker images, then build dual-platform installer scripts that embed compose templates and generate all configuration from user input. The installer supports simple/expert/silent modes with idempotent re-run capability. + +**Tech Stack:** Docker, Bash, PowerShell, Traefik v3, PostgreSQL 16, ClickHouse, Logto (OIDC) + +**Spec:** `docs/superpowers/specs/2026-04-13-install-script-design.md` + +--- + +## File Map + +### Phase 1 — New files (image consolidation) + +| File | Purpose | +|---|---| +| `docker/cameleer-postgres/Dockerfile` | PostgreSQL with init script baked in | +| `docker/cameleer-postgres/init-databases.sh` | Copied from `docker/init-databases.sh` | +| `docker/cameleer-clickhouse/Dockerfile` | ClickHouse with init + config baked in | +| `docker/cameleer-clickhouse/init.sql` | Copied from `docker/clickhouse-init.sql` | +| `docker/cameleer-clickhouse/users.xml` | From `docker/clickhouse-users.xml`, password via `from_env` | +| `docker/cameleer-clickhouse/prometheus.xml` | Copied from `docker/clickhouse-config.xml` | +| `docker/cameleer-traefik/Dockerfile` | Traefik with configs + cert generation baked in | +| `docker/cameleer-traefik/entrypoint.sh` | Cert generation + Traefik startup | +| `docker/cameleer-traefik/traefik.yml` | Copied from root `traefik.yml` | +| `docker/cameleer-traefik/traefik-dynamic.yml` | Copied from `docker/traefik-dynamic.yml` | +| `docker/cameleer-logto/logto-entrypoint.sh` | Wrapper: seed → start → bootstrap → wait | + +### Phase 1 — Modified files + +| File | Change | +|---|---| +| `ui/sign-in/Dockerfile` | Add bootstrap deps + scripts, new entrypoint | +| `docker-compose.yml` | Remove bind mounts, init containers, use new images | +| `.env.example` | Simplify for new compose structure | +| `docker/logto-bootstrap.sh` | Make package install conditional (Alpine vs Debian) | +| `.gitea/workflows/ci.yml` | Add builds for postgres, clickhouse, traefik images; update Logto build context | + +### Phase 1 — Deleted files + +| File | Reason | +|---|---| +| `docker/clickhouse-init.sql` | Moved to `docker/cameleer-clickhouse/init.sql` | +| `docker/clickhouse-users.xml` | Moved (with modification) to `docker/cameleer-clickhouse/users.xml` | +| `docker/clickhouse-config.xml` | Moved to `docker/cameleer-clickhouse/prometheus.xml` | +| `docker/traefik-dynamic.yml` | Moved to `docker/cameleer-traefik/traefik-dynamic.yml` | +| `docker/vendor-seed.sh` | Logic already integrated in `logto-bootstrap.sh` Phase 12 | +| `traefik.yml` | Moved to `docker/cameleer-traefik/traefik.yml` | +| `docker/init-databases.sh` | Moved to `docker/cameleer-postgres/init-databases.sh` | + +### Phase 2 & 3 — New files (installer) + +| File | Purpose | +|---|---| +| `installer/install.sh` | Bash installer for Linux | +| `installer/install.ps1` | PowerShell installer for Windows | + +--- + +## Phase 1: Platform Image Consolidation + +### Task 1: Create cameleer-postgres image + +**Files:** +- Create: `docker/cameleer-postgres/Dockerfile` +- Create: `docker/cameleer-postgres/init-databases.sh` (copy from `docker/init-databases.sh`) + +- [ ] **Step 1: Create the Dockerfile** + +```dockerfile +FROM postgres:16-alpine +COPY init-databases.sh /docker-entrypoint-initdb.d/init-databases.sh +RUN chmod +x /docker-entrypoint-initdb.d/init-databases.sh +``` + +- [ ] **Step 2: Copy the init script** + +```bash +mkdir -p docker/cameleer-postgres +cp docker/init-databases.sh docker/cameleer-postgres/init-databases.sh +``` + +- [ ] **Step 3: Build and verify** + +```bash +docker build -t cameleer-postgres:test docker/cameleer-postgres/ +docker run --rm -e POSTGRES_USER=cameleer -e POSTGRES_PASSWORD=test -e POSTGRES_DB=cameleer_saas \ + cameleer-postgres:test postgres --version +``` + +Expected: PostgreSQL version output, no build errors. + +- [ ] **Step 4: Commit** + +```bash +git add docker/cameleer-postgres/ +git commit -m "feat: create cameleer-postgres image with init script baked in" +``` + +--- + +### Task 2: Create cameleer-clickhouse image + +**Files:** +- Create: `docker/cameleer-clickhouse/Dockerfile` +- Create: `docker/cameleer-clickhouse/init.sql` (copy from `docker/clickhouse-init.sql`) +- Create: `docker/cameleer-clickhouse/users.xml` (from `docker/clickhouse-users.xml`, modified) +- Create: `docker/cameleer-clickhouse/prometheus.xml` (copy from `docker/clickhouse-config.xml`) + +- [ ] **Step 1: Create the Dockerfile** + +```dockerfile +FROM clickhouse/clickhouse-server:latest +COPY init.sql /docker-entrypoint-initdb.d/init.sql +COPY users.xml /etc/clickhouse-server/users.d/default-user.xml +COPY prometheus.xml /etc/clickhouse-server/config.d/prometheus.xml +``` + +- [ ] **Step 2: Copy and modify config files** + +```bash +mkdir -p docker/cameleer-clickhouse +cp docker/clickhouse-init.sql docker/cameleer-clickhouse/init.sql +cp docker/clickhouse-config.xml docker/cameleer-clickhouse/prometheus.xml +``` + +Create `docker/cameleer-clickhouse/users.xml` — same as original but with env-var password: + +```xml + + + + + + + default + + ::/0 + + + default + 0 + + + +``` + +Note: the `from_env` attribute makes the password configurable via the `CLICKHOUSE_PASSWORD` environment variable instead of being hardcoded. + +- [ ] **Step 3: Build and verify** + +```bash +docker build -t cameleer-clickhouse:test docker/cameleer-clickhouse/ +docker run --rm -e CLICKHOUSE_PASSWORD=testpass cameleer-clickhouse:test clickhouse-client --version +``` + +Expected: ClickHouse version output, no build errors. + +- [ ] **Step 4: Commit** + +```bash +git add docker/cameleer-clickhouse/ +git commit -m "feat: create cameleer-clickhouse image with init and config baked in" +``` + +--- + +### Task 3: Create cameleer-traefik image + +**Files:** +- Create: `docker/cameleer-traefik/Dockerfile` +- Create: `docker/cameleer-traefik/entrypoint.sh` +- Create: `docker/cameleer-traefik/traefik.yml` (copy from root `traefik.yml`) +- Create: `docker/cameleer-traefik/traefik-dynamic.yml` (copy from `docker/traefik-dynamic.yml`) + +- [ ] **Step 1: Copy Traefik config files** + +```bash +mkdir -p docker/cameleer-traefik +cp traefik.yml docker/cameleer-traefik/traefik.yml +cp docker/traefik-dynamic.yml docker/cameleer-traefik/traefik-dynamic.yml +``` + +- [ ] **Step 2: Create the entrypoint script** + +`docker/cameleer-traefik/entrypoint.sh`: + +```bash +#!/bin/sh +set -e + +CERTS_DIR="/certs" + +# Skip if certs already exist (idempotent) +if [ ! -f "$CERTS_DIR/cert.pem" ]; then + mkdir -p "$CERTS_DIR" + + if [ -n "$CERT_FILE" ] && [ -n "$KEY_FILE" ]; then + # User-supplied certificate + echo "[certs] Installing user-supplied certificate..." + cp "$CERT_FILE" "$CERTS_DIR/cert.pem" + cp "$KEY_FILE" "$CERTS_DIR/key.pem" + if [ -n "$CA_FILE" ]; then + cp "$CA_FILE" "$CERTS_DIR/ca.pem" + fi + # Validate key matches cert + CERT_MOD=$(openssl x509 -noout -modulus -in "$CERTS_DIR/cert.pem" 2>/dev/null | md5sum) + KEY_MOD=$(openssl rsa -noout -modulus -in "$CERTS_DIR/key.pem" 2>/dev/null | md5sum) + if [ "$CERT_MOD" != "$KEY_MOD" ]; then + echo "[certs] ERROR: Certificate and key do not match!" + rm -f "$CERTS_DIR/cert.pem" "$CERTS_DIR/key.pem" "$CERTS_DIR/ca.pem" + exit 1 + fi + SELF_SIGNED=false + echo "[certs] Installed user-supplied certificate." + else + # Generate self-signed certificate + HOST="${PUBLIC_HOST:-localhost}" + echo "[certs] Generating self-signed certificate for $HOST..." + openssl req -x509 -newkey rsa:4096 \ + -keyout "$CERTS_DIR/key.pem" -out "$CERTS_DIR/cert.pem" \ + -days 365 -nodes \ + -subj "/CN=$HOST" \ + -addext "subjectAltName=DNS:$HOST,DNS:*.$HOST" + SELF_SIGNED=true + echo "[certs] Generated self-signed certificate for $HOST." + fi + + # Write metadata for SaaS app to seed DB + SUBJECT=$(openssl x509 -noout -subject -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/subject=//') + FINGERPRINT=$(openssl x509 -noout -fingerprint -sha256 -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/.*=//') + NOT_BEFORE=$(openssl x509 -noout -startdate -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/notBefore=//') + NOT_AFTER=$(openssl x509 -noout -enddate -in "$CERTS_DIR/cert.pem" 2>/dev/null | sed 's/notAfter=//') + HAS_CA=false + [ -f "$CERTS_DIR/ca.pem" ] && HAS_CA=true + cat > "$CERTS_DIR/meta.json" </dev/null || true +else + echo "[certs] Certificates already exist, skipping generation." +fi + +# Start Traefik +exec traefik "$@" +``` + +- [ ] **Step 3: Create the Dockerfile** + +```dockerfile +FROM traefik:v3 +RUN apk add --no-cache openssl +COPY traefik.yml /etc/traefik/traefik.yml +COPY traefik-dynamic.yml /etc/traefik/dynamic.yml +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] +``` + +- [ ] **Step 4: Build and verify** + +```bash +docker build -t cameleer-traefik:test docker/cameleer-traefik/ +docker run --rm cameleer-traefik:test --version +``` + +Expected: Traefik version output, no build errors. + +- [ ] **Step 5: Commit** + +```bash +git add docker/cameleer-traefik/ +git commit -m "feat: create cameleer-traefik image with cert generation and config baked in" +``` + +--- + +### Task 4: Merge bootstrap into cameleer-logto + +**Files:** +- Create: `docker/cameleer-logto/logto-entrypoint.sh` +- Modify: `ui/sign-in/Dockerfile` +- Modify: `docker/logto-bootstrap.sh` (make package install conditional) + +- [ ] **Step 1: Create the entrypoint wrapper** + +`docker/cameleer-logto/logto-entrypoint.sh`: + +```bash +#!/bin/sh +set -e + +echo "[entrypoint] Seeding Logto database..." +npm run cli db seed -- --swe 2>/dev/null || true + +echo "[entrypoint] Starting Logto..." +npm start & +LOGTO_PID=$! + +echo "[entrypoint] Waiting for Logto to be ready..." +for i in $(seq 1 120); do + if 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>/dev/null; then + echo "[entrypoint] Logto is ready." + break + fi + if [ "$i" -eq 120 ]; then + echo "[entrypoint] ERROR: Logto not ready after 120s" + exit 1 + fi + sleep 1 +done + +# Run bootstrap if not already done +BOOTSTRAP_FILE="/data/logto-bootstrap.json" +if [ -f "$BOOTSTRAP_FILE" ]; then + CACHED_SECRET=$(jq -r '.m2mClientSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null) + CACHED_SPA=$(jq -r '.spaClientId // empty' "$BOOTSTRAP_FILE" 2>/dev/null) + if [ -n "$CACHED_SECRET" ] && [ -n "$CACHED_SPA" ]; then + echo "[entrypoint] Bootstrap already complete." + else + echo "[entrypoint] Incomplete bootstrap found, re-running..." + /scripts/logto-bootstrap.sh + fi +else + echo "[entrypoint] Running bootstrap..." + /scripts/logto-bootstrap.sh +fi + +echo "[entrypoint] Logto is running (PID $LOGTO_PID)." +wait $LOGTO_PID +``` + +- [ ] **Step 2: Make bootstrap script package-install conditional** + +In `docker/logto-bootstrap.sh`, replace line 51: + +```bash +# Install jq + curl +apk add --no-cache jq curl >/dev/null 2>&1 +``` + +With: + +```bash +# Install jq + curl if not already available (deps are baked into cameleer-logto image) +if ! command -v jq >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then + if command -v apk >/dev/null 2>&1; then + apk add --no-cache jq curl >/dev/null 2>&1 + elif command -v apt-get >/dev/null 2>&1; then + apt-get update -qq && apt-get install -y -qq jq curl >/dev/null 2>&1 + fi +fi +``` + +- [ ] **Step 3: Update ui/sign-in/Dockerfile** + +Replace the entire Dockerfile content with: + +```dockerfile +# syntax=docker/dockerfile:1 + +# Stage 1: Build custom sign-in UI +FROM --platform=$BUILDPLATFORM node:22-alpine AS build +ARG REGISTRY_TOKEN +WORKDIR /ui +COPY ui/sign-in/package.json ui/sign-in/package-lock.json ui/sign-in/.npmrc ./ +RUN --mount=type=cache,target=/root/.npm echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc && npm ci +COPY ui/sign-in/ . +RUN npm run build + +# Stage 2: Logto with sign-in UI + bootstrap +FROM ghcr.io/logto-io/logto:latest + +# Install bootstrap dependencies (curl, jq for API calls; postgresql-client for DB reads) +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl jq postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Custom sign-in UI +COPY --from=build /ui/dist/ /etc/logto/packages/experience/dist/ + +# Bootstrap scripts +COPY docker/logto-bootstrap.sh /scripts/logto-bootstrap.sh +COPY docker/cameleer-logto/logto-entrypoint.sh /scripts/entrypoint.sh +RUN chmod +x /scripts/*.sh + +ENTRYPOINT ["/scripts/entrypoint.sh"] +``` + +Note: This Dockerfile now requires the **repo root** as build context (not `ui/sign-in/`). The CI workflow update in Task 6 handles this. + +- [ ] **Step 4: Build and verify** + +```bash +mkdir -p docker/cameleer-logto +docker build -f ui/sign-in/Dockerfile -t cameleer-logto:test . +``` + +Expected: successful build (sign-in UI + bootstrap deps installed). + +- [ ] **Step 5: Commit** + +```bash +git add docker/cameleer-logto/ ui/sign-in/Dockerfile docker/logto-bootstrap.sh +git commit -m "feat: merge bootstrap into cameleer-logto image + +Adds logto-entrypoint.sh that seeds DB, starts Logto, waits for health, +runs bootstrap, then keeps Logto running. Eliminates the separate +logto-bootstrap init container." +``` + +--- + +### Task 5: Update docker-compose.yml and clean up old files + +**Files:** +- Modify: `docker-compose.yml` +- Modify: `.env.example` +- Delete: `docker/init-databases.sh`, `docker/clickhouse-init.sql`, `docker/clickhouse-users.xml`, `docker/clickhouse-config.xml`, `docker/traefik-dynamic.yml`, `docker/vendor-seed.sh`, `traefik.yml` + +- [ ] **Step 1: Replace docker-compose.yml** + +Replace the entire `docker-compose.yml` with: + +```yaml +services: + traefik: + image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest} + restart: unless-stopped + ports: + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" + - "${LOGTO_CONSOLE_PORT:-3002}:3002" + environment: + PUBLIC_HOST: ${PUBLIC_HOST:-localhost} + CERT_FILE: ${CERT_FILE:-} + KEY_FILE: ${KEY_FILE:-} + CA_FILE: ${CA_FILE:-} + volumes: + - certs:/certs + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - cameleer + - cameleer-traefik + + postgres: + image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest} + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas} + POSTGRES_USER: ${POSTGRES_USER:-cameleer} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-cameleer} -d ${POSTGRES_DB:-cameleer_saas}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - cameleer + + clickhouse: + image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest} + restart: unless-stopped + environment: + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-cameleer_ch} + volumes: + - chdata:/var/lib/clickhouse + healthcheck: + test: ["CMD-SHELL", "clickhouse-client --password ${CLICKHOUSE_PASSWORD:-cameleer_ch} --query 'SELECT 1'"] + interval: 10s + timeout: 5s + retries: 3 + labels: + - prometheus.scrape=true + - prometheus.path=/metrics + - prometheus.port=9363 + networks: + - cameleer + + logto: + image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest} + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@postgres:5432/logto + ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} + ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002} + TRUST_PROXY_HEADER: 1 + NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}" + LOGTO_ENDPOINT: http://logto:3001 + LOGTO_ADMIN_ENDPOINT: http://logto:3002 + LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} + PUBLIC_HOST: ${PUBLIC_HOST:-localhost} + PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https} + PG_HOST: postgres + PG_USER: ${POSTGRES_USER:-cameleer} + PG_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev} + PG_DB_SAAS: ${POSTGRES_DB:-cameleer_saas} + SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin} + SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin} + VENDOR_SEED_ENABLED: "${VENDOR_SEED_ENABLED:-false}" + VENDOR_USER: ${VENDOR_USER:-vendor} + VENDOR_PASS: ${VENDOR_PASS:-vendor} + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"] + interval: 10s + timeout: 5s + retries: 60 + start_period: 30s + labels: + - traefik.enable=true + - traefik.http.routers.logto.rule=PathPrefix(`/`) + - traefik.http.routers.logto.priority=1 + - traefik.http.routers.logto.entrypoints=websecure + - traefik.http.routers.logto.tls=true + - traefik.http.routers.logto.service=logto + - traefik.http.routers.logto.middlewares=logto-cors + - traefik.http.middlewares.logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002} + - traefik.http.middlewares.logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS + - traefik.http.middlewares.logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type + - traefik.http.middlewares.logto-cors.headers.accessControlAllowCredentials=true + - traefik.http.services.logto.loadbalancer.server.port=3001 + - traefik.http.routers.logto-console.rule=PathPrefix(`/`) + - traefik.http.routers.logto-console.entrypoints=admin-console + - traefik.http.routers.logto-console.tls=true + - traefik.http.routers.logto-console.service=logto-console + - traefik.http.services.logto-console.loadbalancer.server.port=3002 + volumes: + - bootstrapdata:/data + networks: + - cameleer + + cameleer-saas: + image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest} + restart: unless-stopped + depends_on: + logto: + condition: service_healthy + volumes: + - bootstrapdata:/data/bootstrap:ro + - certs:/certs + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev} + CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: ${LOGTO_ENDPOINT:-http://logto:3001} + CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} + CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https} + CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost} + CAMELEER_SAAS_IDENTITY_M2MCLIENTID: ${LOGTO_M2M_CLIENT_ID:-} + CAMELEER_SAAS_IDENTITY_M2MCLIENTSECRET: ${LOGTO_M2M_CLIENT_SECRET:-} + 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 + networks: + - cameleer + +networks: + cameleer: + driver: bridge + cameleer-traefik: + name: cameleer-traefik + driver: bridge + +volumes: + pgdata: + chdata: + certs: + bootstrapdata: +``` + +Key changes from original: +- `traefik-certs` init container removed (merged into `cameleer-traefik` entrypoint) +- `logto-bootstrap` init container removed (merged into `cameleer-logto` entrypoint) +- All bind-mounted config files removed +- All images use `${*_IMAGE:-gitea.siegeln.net/cameleer/...}` pattern +- Logto healthcheck includes `test -f /data/logto-bootstrap.json` (passes only after bootstrap) +- `cameleer-saas` depends on `logto: service_healthy` (not `logto-bootstrap: service_completed_successfully`) +- Ports configurable via env vars (`HTTP_PORT`, `HTTPS_PORT`, `LOGTO_CONSOLE_PORT`) +- `NODE_TLS_REJECT` configurable via env var + +- [ ] **Step 2: Update .env.example** + +Replace the entire `.env.example` with: + +```bash +# Cameleer SaaS — Environment Configuration +# Copy to .env and fill in values for production + +# Image version +VERSION=latest + +# Public access +PUBLIC_HOST=localhost +PUBLIC_PROTOCOL=https + +# Ports +HTTP_PORT=80 +HTTPS_PORT=443 +LOGTO_CONSOLE_PORT=3002 + +# PostgreSQL +POSTGRES_USER=cameleer +POSTGRES_PASSWORD=change_me_in_production +POSTGRES_DB=cameleer_saas + +# ClickHouse +CLICKHOUSE_PASSWORD=change_me_in_production + +# Admin user (created by bootstrap) +SAAS_ADMIN_USER=admin +SAAS_ADMIN_PASS=change_me_in_production + +# TLS (leave empty for self-signed) +# NODE_TLS_REJECT=0 # Set to 1 when using real certificates +# CERT_FILE= +# KEY_FILE= +# CA_FILE= + +# Vendor account (optional) +VENDOR_SEED_ENABLED=false +# VENDOR_USER=vendor +# VENDOR_PASS=change_me + +# Docker images (override for custom registries) +# TRAEFIK_IMAGE=gitea.siegeln.net/cameleer/cameleer-traefik +# POSTGRES_IMAGE=gitea.siegeln.net/cameleer/cameleer-postgres +# CLICKHOUSE_IMAGE=gitea.siegeln.net/cameleer/cameleer-clickhouse +# LOGTO_IMAGE=gitea.siegeln.net/cameleer/cameleer-logto +# CAMELEER_IMAGE=gitea.siegeln.net/cameleer/cameleer-saas +``` + +- [ ] **Step 3: Delete old files** + +```bash +rm docker/init-databases.sh +rm docker/clickhouse-init.sql +rm docker/clickhouse-users.xml +rm docker/clickhouse-config.xml +rm docker/traefik-dynamic.yml +rm docker/vendor-seed.sh +rm traefik.yml +``` + +- [ ] **Step 4: Commit** + +```bash +git add docker-compose.yml .env.example +git rm docker/init-databases.sh docker/clickhouse-init.sql docker/clickhouse-users.xml \ + docker/clickhouse-config.xml docker/traefik-dynamic.yml docker/vendor-seed.sh traefik.yml +git commit -m "feat: consolidate docker-compose.yml for baked-in images + +Remove all bind-mounted config files and init containers. Services +reduced from 7 to 5. All configuration via environment variables." +``` + +--- + +### Task 6: Update CI workflows + +**Files:** +- Modify: `.gitea/workflows/ci.yml` + +- [ ] **Step 1: Add image builds for postgres, clickhouse, traefik** + +Add these build steps after the existing "Build and push Logto image" step in the `docker` job: + +```yaml + - name: Build and push PostgreSQL image + run: | + TAGS="-t gitea.siegeln.net/cameleer/cameleer-postgres:${{ github.sha }}" + for TAG in $IMAGE_TAGS; do + TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-postgres:$TAG" + done + docker buildx build --platform linux/amd64 \ + $TAGS \ + --provenance=false \ + --push docker/cameleer-postgres/ + + - name: Build and push ClickHouse image + run: | + TAGS="-t gitea.siegeln.net/cameleer/cameleer-clickhouse:${{ github.sha }}" + for TAG in $IMAGE_TAGS; do + TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-clickhouse:$TAG" + done + docker buildx build --platform linux/amd64 \ + $TAGS \ + --provenance=false \ + --push docker/cameleer-clickhouse/ + + - name: Build and push Traefik image + run: | + TAGS="-t gitea.siegeln.net/cameleer/cameleer-traefik:${{ github.sha }}" + for TAG in $IMAGE_TAGS; do + TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-traefik:$TAG" + done + docker buildx build --platform linux/amd64 \ + $TAGS \ + --provenance=false \ + --push docker/cameleer-traefik/ +``` + +- [ ] **Step 2: Update Logto build to use repo root context** + +Change the existing "Build and push Logto image" step. Replace the `docker buildx build` line: + +```yaml + - name: Build and push Logto image + run: | + TAGS="-t gitea.siegeln.net/cameleer/cameleer-logto:${{ github.sha }}" + for TAG in $IMAGE_TAGS; do + TAGS="$TAGS -t gitea.siegeln.net/cameleer/cameleer-logto:$TAG" + done + docker buildx build --platform linux/amd64 \ + --build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \ + -f ui/sign-in/Dockerfile \ + $TAGS \ + --cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer-logto:buildcache \ + --cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer-logto:buildcache,mode=max \ + --provenance=false \ + --push . + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} +``` + +Key change: build context is now `.` (repo root) instead of `ui/sign-in/`, and `-f ui/sign-in/Dockerfile` is added. + +- [ ] **Step 3: Commit** + +```bash +git add .gitea/workflows/ci.yml +git commit -m "ci: add builds for cameleer-postgres, cameleer-clickhouse, cameleer-traefik + +Update Logto build to use repo root context for bootstrap script access." +``` + +--- + +### Task 7: Validate consolidated stack + +**Files:** None (testing only) + +- [ ] **Step 1: Start the consolidated stack** + +```bash +docker compose up -d +``` + +- [ ] **Step 2: Verify all services start and become healthy** + +```bash +docker compose ps +``` + +Expected: 5 services, all "Up" or "Up (healthy)". No `traefik-certs` or `logto-bootstrap` containers. + +- [ ] **Step 3: Verify bootstrap completed** + +```bash +docker compose exec logto cat /data/logto-bootstrap.json | jq . +``` + +Expected: JSON with `spaClientId`, `m2mClientId`, `m2mClientSecret`, `tradAppId`, `tradAppSecret`. + +- [ ] **Step 4: Verify Traefik routing** + +```bash +curl -sk https://localhost/platform/api/config | jq . +``` + +Expected: JSON with `logtoEndpoint`, `spaClientId`, `scopes`. + +- [ ] **Step 5: Verify certs were generated** + +```bash +docker compose exec traefik cat /certs/meta.json | jq . +``` + +Expected: JSON with `selfSigned: true`, `subject` containing localhost. + +- [ ] **Step 6: Clean up test and commit any fixes** + +```bash +docker compose down +``` + +If any issues were found and fixed, commit the fixes. + +--- + +## Phase 2: Bash Installer + +> **Note:** Phase 2 can be developed in parallel with Phase 1. The installer generates files and runs docker commands — it doesn't need the actual images until Task 17 (end-to-end testing). + +### Task 8: Installer skeleton and utility functions + +**Files:** +- Create: `installer/install.sh` + +- [ ] **Step 1: Create the installer with header, constants, and utilities** + +`installer/install.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +CAMELEER_INSTALLER_VERSION="1.0.0" +CAMELEER_DEFAULT_VERSION="latest" +REGISTRY="gitea.siegeln.net/cameleer" + +# --- Colors --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +# --- Defaults --- +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="/var/run/docker.sock" + +# --- Config values (populated by args/env/config/prompts) --- +INSTALL_DIR="" +PUBLIC_HOST="" +PUBLIC_PROTOCOL="" +ADMIN_USER="" +ADMIN_PASS="" +TLS_MODE="" +CERT_FILE="" +KEY_FILE="" +CA_FILE="" +POSTGRES_PASSWORD="" +CLICKHOUSE_PASSWORD="" +HTTP_PORT="" +HTTPS_PORT="" +LOGTO_CONSOLE_PORT="" +LOGTO_CONSOLE_EXPOSED="" +VENDOR_ENABLED="" +VENDOR_USER="" +VENDOR_PASS="" +MONITORING_NETWORK="" +VERSION="" +COMPOSE_PROJECT="" +DOCKER_SOCKET="" +NODE_TLS_REJECT="" + +# --- State --- +MODE="" # simple, expert, silent +IS_RERUN=false +RERUN_ACTION="" # upgrade, reconfigure, reinstall +CONFIG_FILE_PATH="" + +# --- Utility functions --- + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_success() { echo -e "${GREEN}[OK]${NC} $1"; } + +print_banner() { + echo -e "${BOLD}" + echo " ____ _ " + echo " / ___|__ _ _ __ ___ ___ | | ___ ___ _ __ " + echo "| | / _\` | '_ \` _ \\ / _ \\| |/ _ \\/ _ \\ '__|" + echo "| |__| (_| | | | | | | __/| | __/ __/ | " + echo " \\____\\__,_|_| |_| |_|\\___||_|\\___|\\___||_| " + echo "" + echo " SaaS Platform Installer v${CAMELEER_INSTALLER_VERSION}" + echo -e "${NC}" +} + +prompt() { + local var_name="$1" prompt_text="$2" default="${3:-}" + local input + if [ -n "$default" ]; then + read -rp " $prompt_text [$default]: " input + eval "$var_name=\"\${input:-$default}\"" + else + read -rp " $prompt_text: " input + eval "$var_name=\"\$input\"" + fi +} + +prompt_password() { + local var_name="$1" prompt_text="$2" default="${3:-}" + local input + if [ -n "$default" ]; then + read -rsp " $prompt_text [${default:+********}]: " input + echo + eval "$var_name=\"\${input:-$default}\"" + else + read -rsp " $prompt_text: " input + echo + eval "$var_name=\"\$input\"" + fi +} + +prompt_yesno() { + local prompt_text="$1" default="${2:-n}" + local input + if [ "$default" = "y" ]; then + read -rp " $prompt_text [Y/n]: " input + case "${input:-y}" in + [nN]|[nN][oO]) return 1 ;; *) return 0 ;; esac + else + read -rp " $prompt_text [y/N]: " input + case "${input:-n}" in + [yY]|[yY][eE][sS]) return 0 ;; *) return 1 ;; esac + fi +} + +generate_password() { + openssl rand -base64 24 | tr -d '/+=' | head -c 32 +} +``` + +- [ ] **Step 2: Make executable and verify syntax** + +```bash +mkdir -p installer +chmod +x installer/install.sh +bash -n installer/install.sh +``` + +Expected: no syntax errors. + +- [ ] **Step 3: Commit** + +```bash +git add installer/install.sh +git commit -m "feat(installer): scaffold install.sh with constants and utilities" +``` + +--- + +### Task 9: Argument parsing and config file handling + +**Files:** +- Modify: `installer/install.sh` + +- [ ] **Step 1: Add parse_args function** + +Append to `installer/install.sh` before the closing (there is no closing yet, so just append): + +```bash +# --- Argument parsing --- + +parse_args() { + while [ $# -gt 0 ]; do + case "$1" in + --silent) MODE="silent" ;; + --expert) MODE="expert" ;; + --config) CONFIG_FILE_PATH="$2"; shift ;; + --install-dir) INSTALL_DIR="$2"; shift ;; + --public-host) PUBLIC_HOST="$2"; shift ;; + --public-protocol) PUBLIC_PROTOCOL="$2"; shift ;; + --admin-user) ADMIN_USER="$2"; shift ;; + --admin-password) ADMIN_PASS="$2"; shift ;; + --tls-mode) TLS_MODE="$2"; shift ;; + --cert-file) CERT_FILE="$2"; shift ;; + --key-file) KEY_FILE="$2"; shift ;; + --ca-file) CA_FILE="$2"; shift ;; + --postgres-password) POSTGRES_PASSWORD="$2"; shift ;; + --clickhouse-password) CLICKHOUSE_PASSWORD="$2"; shift ;; + --http-port) HTTP_PORT="$2"; shift ;; + --https-port) HTTPS_PORT="$2"; shift ;; + --logto-console-port) LOGTO_CONSOLE_PORT="$2"; shift ;; + --logto-console-exposed) LOGTO_CONSOLE_EXPOSED="$2"; shift ;; + --vendor-enabled) VENDOR_ENABLED="$2"; shift ;; + --vendor-user) VENDOR_USER="$2"; shift ;; + --vendor-password) VENDOR_PASS="$2"; shift ;; + --monitoring-network) MONITORING_NETWORK="$2"; shift ;; + --version) VERSION="$2"; shift ;; + --compose-project) COMPOSE_PROJECT="$2"; shift ;; + --docker-socket) DOCKER_SOCKET="$2"; shift ;; + --node-tls-reject) NODE_TLS_REJECT="$2"; shift ;; + --reconfigure) RERUN_ACTION="reconfigure" ;; + --reinstall) RERUN_ACTION="reinstall" ;; + --confirm-destroy) CONFIRM_DESTROY=true ;; + --help|-h) show_help; exit 0 ;; + *) + log_error "Unknown option: $1" + echo " Run with --help for usage." + exit 1 + ;; + esac + shift + done +} + +show_help() { + echo "Usage: install.sh [OPTIONS]" + echo "" + echo "Modes:" + echo " (default) Interactive simple mode (6 questions)" + echo " --expert Interactive expert mode (all options)" + echo " --silent Non-interactive, use defaults + overrides" + echo "" + echo "Options:" + echo " --install-dir DIR Install directory (default: ./cameleer)" + echo " --public-host HOST Public hostname (default: auto-detect)" + echo " --admin-user USER Admin username (default: admin)" + echo " --admin-password PASS Admin password (default: generated)" + echo " --tls-mode MODE self-signed or custom (default: self-signed)" + echo " --cert-file PATH TLS certificate file" + echo " --key-file PATH TLS key file" + echo " --ca-file PATH CA bundle file" + echo " --monitoring-network NAME Docker network for Prometheus scraping" + echo " --version TAG Image version tag (default: latest)" + echo " --config FILE Load config from file" + echo " --help Show this help" + echo "" + echo "Expert options:" + echo " --postgres-password, --clickhouse-password, --http-port," + echo " --https-port, --logto-console-port, --logto-console-exposed," + echo " --vendor-enabled, --vendor-user, --vendor-password," + echo " --compose-project, --docker-socket, --node-tls-reject" + echo "" + echo "Re-run options:" + echo " --reconfigure Re-run interactive setup (preserve data)" + echo " --reinstall --confirm-destroy Fresh install (destroys data)" + echo "" + echo "Config precedence: CLI flags > env vars > config file > defaults" +} +``` + +- [ ] **Step 2: Add config file loading** + +Append: + +```bash +# --- Config file handling --- + +load_config_file() { + local file="$1" + [ ! -f "$file" ] && return + while IFS='=' read -r key value; do + # Skip comments and blank lines + case "$key" in + \#*|"") continue ;; + esac + # Only set if not already set by CLI args + key=$(echo "$key" | tr -d ' ') + value=$(echo "$value" | sed 's/^[ ]*//;s/[ ]*$//') + case "$key" in + install_dir) [ -z "$INSTALL_DIR" ] && INSTALL_DIR="$value" ;; + public_host) [ -z "$PUBLIC_HOST" ] && PUBLIC_HOST="$value" ;; + public_protocol) [ -z "$PUBLIC_PROTOCOL" ] && PUBLIC_PROTOCOL="$value" ;; + admin_user) [ -z "$ADMIN_USER" ] && ADMIN_USER="$value" ;; + admin_password) [ -z "$ADMIN_PASS" ] && ADMIN_PASS="$value" ;; + tls_mode) [ -z "$TLS_MODE" ] && TLS_MODE="$value" ;; + cert_file) [ -z "$CERT_FILE" ] && CERT_FILE="$value" ;; + key_file) [ -z "$KEY_FILE" ] && KEY_FILE="$value" ;; + ca_file) [ -z "$CA_FILE" ] && CA_FILE="$value" ;; + postgres_password) [ -z "$POSTGRES_PASSWORD" ] && POSTGRES_PASSWORD="$value" ;; + clickhouse_password) [ -z "$CLICKHOUSE_PASSWORD" ] && CLICKHOUSE_PASSWORD="$value" ;; + http_port) [ -z "$HTTP_PORT" ] && HTTP_PORT="$value" ;; + https_port) [ -z "$HTTPS_PORT" ] && HTTPS_PORT="$value" ;; + logto_console_port) [ -z "$LOGTO_CONSOLE_PORT" ] && LOGTO_CONSOLE_PORT="$value" ;; + logto_console_exposed) [ -z "$LOGTO_CONSOLE_EXPOSED" ] && LOGTO_CONSOLE_EXPOSED="$value" ;; + vendor_enabled) [ -z "$VENDOR_ENABLED" ] && VENDOR_ENABLED="$value" ;; + vendor_user) [ -z "$VENDOR_USER" ] && VENDOR_USER="$value" ;; + vendor_password) [ -z "$VENDOR_PASS" ] && VENDOR_PASS="$value" ;; + monitoring_network) [ -z "$MONITORING_NETWORK" ] && MONITORING_NETWORK="$value" ;; + version) [ -z "$VERSION" ] && VERSION="$value" ;; + compose_project) [ -z "$COMPOSE_PROJECT" ] && COMPOSE_PROJECT="$value" ;; + docker_socket) [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="$value" ;; + node_tls_reject) [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="$value" ;; + esac + done < "$file" +} + +load_env_overrides() { + # Apply env vars for any values not already set by CLI args + [ -z "$INSTALL_DIR" ] && INSTALL_DIR="${CAMELEER_INSTALL_DIR:-}" + [ -z "$PUBLIC_HOST" ] && PUBLIC_HOST="${PUBLIC_HOST:-}" + [ -z "$PUBLIC_PROTOCOL" ] && PUBLIC_PROTOCOL="${PUBLIC_PROTOCOL:-}" + [ -z "$ADMIN_USER" ] && ADMIN_USER="${SAAS_ADMIN_USER:-}" + [ -z "$ADMIN_PASS" ] && ADMIN_PASS="${SAAS_ADMIN_PASS:-}" + [ -z "$TLS_MODE" ] && TLS_MODE="${TLS_MODE:-}" + [ -z "$POSTGRES_PASSWORD" ] && POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" + [ -z "$CLICKHOUSE_PASSWORD" ] && CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-}" + [ -z "$HTTP_PORT" ] && HTTP_PORT="${HTTP_PORT:-}" + [ -z "$HTTPS_PORT" ] && HTTPS_PORT="${HTTPS_PORT:-}" + [ -z "$LOGTO_CONSOLE_PORT" ] && LOGTO_CONSOLE_PORT="${LOGTO_CONSOLE_PORT:-}" + [ -z "$LOGTO_CONSOLE_EXPOSED" ] && LOGTO_CONSOLE_EXPOSED="${LOGTO_CONSOLE_EXPOSED:-}" + [ -z "$VENDOR_ENABLED" ] && VENDOR_ENABLED="${VENDOR_ENABLED:-}" + [ -z "$VENDOR_USER" ] && VENDOR_USER="${VENDOR_USER:-}" + [ -z "$VENDOR_PASS" ] && VENDOR_PASS="${VENDOR_PASS:-}" + [ -z "$MONITORING_NETWORK" ] && MONITORING_NETWORK="${MONITORING_NETWORK:-}" + [ -z "$VERSION" ] && VERSION="${CAMELEER_VERSION:-}" + [ -z "$COMPOSE_PROJECT" ] && COMPOSE_PROJECT="${COMPOSE_PROJECT:-}" + [ -z "$DOCKER_SOCKET" ] && DOCKER_SOCKET="${DOCKER_SOCKET:-}" + [ -z "$NODE_TLS_REJECT" ] && NODE_TLS_REJECT="${NODE_TLS_REJECT:-}" +} +``` + +- [ ] **Step 3: Verify syntax** + +```bash +bash -n installer/install.sh +``` + +- [ ] **Step 4: Commit** + +```bash +git add installer/install.sh +git commit -m "feat(installer): add argument parsing and config file handling" +``` + +--- + +### Task 10: Prerequisite checks and auto-detection + +**Files:** +- Modify: `installer/install.sh` + +- [ ] **Step 1: Add prerequisite checks** + +Append to `installer/install.sh`: + +```bash +# --- Prerequisites --- + +check_prerequisites() { + log_info "Checking prerequisites..." + local errors=0 + + # Docker + if ! command -v docker >/dev/null 2>&1; then + log_error "Docker is not installed." + echo " Install Docker Engine: https://docs.docker.com/engine/install/" + errors=$((errors + 1)) + else + local docker_version + docker_version=$(docker version --format '{{.Server.Version}}' 2>/dev/null || echo "unknown") + log_info "Docker version: $docker_version" + fi + + # Docker Compose v2 + if ! docker compose version >/dev/null 2>&1; then + log_error "Docker Compose v2 is not available." + echo " 'docker compose' subcommand required (not standalone docker-compose)." + errors=$((errors + 1)) + else + local compose_version + compose_version=$(docker compose version --short 2>/dev/null || echo "unknown") + log_info "Docker Compose version: $compose_version" + fi + + # OpenSSL (for password generation) + if ! command -v openssl >/dev/null 2>&1; then + log_error "OpenSSL is not installed (needed for password generation)." + errors=$((errors + 1)) + fi + + # Docker socket + local socket="${DOCKER_SOCKET:-$DEFAULT_DOCKER_SOCKET}" + if [ ! -S "$socket" ]; then + log_error "Docker socket not found at $socket" + errors=$((errors + 1)) + fi + + # Port availability + check_port_available "${HTTP_PORT:-$DEFAULT_HTTP_PORT}" "HTTP" + check_port_available "${HTTPS_PORT:-$DEFAULT_HTTPS_PORT}" "HTTPS" + check_port_available "${LOGTO_CONSOLE_PORT:-$DEFAULT_LOGTO_CONSOLE_PORT}" "Logto Console" + + if [ $errors -gt 0 ]; then + log_error "$errors prerequisite(s) not met. Please install missing dependencies and retry." + exit 1 + fi + log_success "All prerequisites met." +} + +check_port_available() { + local port="$1" name="$2" + if ss -tlnp 2>/dev/null | grep -q ":${port} " || \ + netstat -tlnp 2>/dev/null | grep -q ":${port} "; then + log_warn "Port $port ($name) is already in use." + fi +} +``` + +- [ ] **Step 2: Add auto-detection** + +Append: + +```bash +# --- Auto-detection --- + +auto_detect() { + # Public hostname + if [ -z "$PUBLIC_HOST" ]; then + PUBLIC_HOST=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "localhost") + # Try reverse DNS of primary IP + local primary_ip + primary_ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}' || true) + if [ -n "$primary_ip" ]; then + local rdns + rdns=$(dig +short -x "$primary_ip" 2>/dev/null | sed 's/\.$//' || true) + [ -n "$rdns" ] && PUBLIC_HOST="$rdns" + fi + fi + + # Docker socket + if [ -z "$DOCKER_SOCKET" ]; then + DOCKER_SOCKET="$DEFAULT_DOCKER_SOCKET" + fi +} + +detect_existing_install() { + local dir="${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}" + if [ -f "$dir/cameleer.conf" ]; then + IS_RERUN=true + INSTALL_DIR="$dir" + load_config_file "$dir/cameleer.conf" + fi +} +``` + +- [ ] **Step 3: Verify syntax** + +```bash +bash -n installer/install.sh +``` + +- [ ] **Step 4: Commit** + +```bash +git add installer/install.sh +git commit -m "feat(installer): add prerequisite checks and auto-detection" +``` + +--- + +### Task 11: Interactive prompts + +**Files:** +- Modify: `installer/install.sh` + +- [ ] **Step 1: Add mode selection and simple prompts** + +Append: + +```bash +# --- Interactive prompts --- + +select_mode() { + if [ -n "$MODE" ]; then return; fi + echo "" + echo " Installation mode:" + echo " [1] Simple — 6 questions, sensible defaults (recommended)" + echo " [2] Expert — configure everything" + echo "" + local choice + read -rp " Select mode [1]: " choice + case "${choice:-1}" in + 2) MODE="expert" ;; + *) MODE="simple" ;; + esac +} + +run_simple_prompts() { + echo "" + echo -e "${BOLD}--- Simple Installation ---${NC}" + echo "" + + prompt INSTALL_DIR "Install directory" "$DEFAULT_INSTALL_DIR" + prompt PUBLIC_HOST "Public hostname" "${PUBLIC_HOST:-localhost}" + prompt ADMIN_USER "Admin username" "$DEFAULT_ADMIN_USER" + + if prompt_yesno "Auto-generate admin password?"; then + ADMIN_PASS="" # will be generated later + else + prompt_password ADMIN_PASS "Admin password" "" + fi + + echo "" + if prompt_yesno "Use custom TLS certificates? (no = self-signed)"; then + TLS_MODE="custom" + prompt CERT_FILE "Path to certificate file (PEM)" "" + prompt KEY_FILE "Path to private key file (PEM)" "" + if prompt_yesno "Include CA bundle?"; then + prompt CA_FILE "Path to CA bundle (PEM)" "" + fi + else + TLS_MODE="self-signed" + fi + + echo "" + prompt MONITORING_NETWORK "Monitoring network name (empty = skip)" "" +} + +run_expert_prompts() { + echo "" + echo -e "${BOLD}--- Expert Installation ---${NC}" + + # Start with all simple-mode questions + run_simple_prompts + + echo "" + echo -e "${BOLD} Credentials:${NC}" + if prompt_yesno "Auto-generate database passwords?" "y"; then + POSTGRES_PASSWORD="" + CLICKHOUSE_PASSWORD="" + else + prompt_password POSTGRES_PASSWORD "PostgreSQL password" "" + prompt_password CLICKHOUSE_PASSWORD "ClickHouse password" "" + fi + + echo "" + if prompt_yesno "Enable vendor account?"; then + VENDOR_ENABLED="true" + prompt VENDOR_USER "Vendor username" "$DEFAULT_VENDOR_USER" + if prompt_yesno "Auto-generate vendor password?" "y"; then + VENDOR_PASS="" + else + prompt_password VENDOR_PASS "Vendor password" "" + fi + else + VENDOR_ENABLED="false" + fi + + echo "" + echo -e "${BOLD} Networking:${NC}" + prompt HTTP_PORT "HTTP port" "$DEFAULT_HTTP_PORT" + prompt HTTPS_PORT "HTTPS port" "$DEFAULT_HTTPS_PORT" + prompt LOGTO_CONSOLE_PORT "Logto admin console port" "$DEFAULT_LOGTO_CONSOLE_PORT" + + echo "" + echo -e "${BOLD} Docker:${NC}" + prompt VERSION "Image version/tag" "$CAMELEER_DEFAULT_VERSION" + prompt COMPOSE_PROJECT "Compose project name" "$DEFAULT_COMPOSE_PROJECT" + prompt DOCKER_SOCKET "Docker socket path" "$DEFAULT_DOCKER_SOCKET" + + echo "" + echo -e "${BOLD} Logto:${NC}" + if prompt_yesno "Expose Logto admin console externally?" "y"; then + LOGTO_CONSOLE_EXPOSED="true" + else + LOGTO_CONSOLE_EXPOSED="false" + fi +} +``` + +- [ ] **Step 2: Verify syntax** + +```bash +bash -n installer/install.sh +``` + +- [ ] **Step 3: Commit** + +```bash +git add installer/install.sh +git commit -m "feat(installer): add interactive prompts for simple and expert modes" +``` + +--- + +### Task 12: Config merge, validation, and password generation + +**Files:** +- Modify: `installer/install.sh` + +- [ ] **Step 1: Add merge, validate, and password functions** + +Append: + +```bash +# --- Config merge and validation --- + +merge_config() { + # Apply defaults for any remaining empty values + : "${INSTALL_DIR:=$DEFAULT_INSTALL_DIR}" + : "${PUBLIC_HOST:=localhost}" + : "${PUBLIC_PROTOCOL:=$DEFAULT_PUBLIC_PROTOCOL}" + : "${ADMIN_USER:=$DEFAULT_ADMIN_USER}" + : "${TLS_MODE:=$DEFAULT_TLS_MODE}" + : "${HTTP_PORT:=$DEFAULT_HTTP_PORT}" + : "${HTTPS_PORT:=$DEFAULT_HTTPS_PORT}" + : "${LOGTO_CONSOLE_PORT:=$DEFAULT_LOGTO_CONSOLE_PORT}" + : "${LOGTO_CONSOLE_EXPOSED:=$DEFAULT_LOGTO_CONSOLE_EXPOSED}" + : "${VENDOR_ENABLED:=$DEFAULT_VENDOR_ENABLED}" + : "${VENDOR_USER:=$DEFAULT_VENDOR_USER}" + : "${VERSION:=$CAMELEER_DEFAULT_VERSION}" + : "${COMPOSE_PROJECT:=$DEFAULT_COMPOSE_PROJECT}" + : "${DOCKER_SOCKET:=$DEFAULT_DOCKER_SOCKET}" + + # NODE_TLS_REJECT depends on TLS mode + if [ -z "$NODE_TLS_REJECT" ]; then + if [ "$TLS_MODE" = "custom" ]; then + NODE_TLS_REJECT="1" + else + NODE_TLS_REJECT="0" + fi + fi +} + +validate_config() { + local errors=0 + + if [ "$TLS_MODE" = "custom" ]; then + if [ ! -f "$CERT_FILE" ]; then + log_error "Certificate file not found: $CERT_FILE" + errors=$((errors + 1)) + fi + if [ ! -f "$KEY_FILE" ]; then + log_error "Key file not found: $KEY_FILE" + errors=$((errors + 1)) + fi + if [ -n "$CA_FILE" ] && [ ! -f "$CA_FILE" ]; then + log_error "CA bundle not found: $CA_FILE" + errors=$((errors + 1)) + fi + fi + + # Validate port numbers + for port_var in HTTP_PORT HTTPS_PORT LOGTO_CONSOLE_PORT; do + local port_val + eval "port_val=\$$port_var" + if ! echo "$port_val" | grep -qE '^[0-9]+$' || [ "$port_val" -lt 1 ] || [ "$port_val" -gt 65535 ]; then + log_error "Invalid port for $port_var: $port_val" + errors=$((errors + 1)) + fi + done + + if [ $errors -gt 0 ]; then + log_error "Configuration validation failed." + exit 1 + fi + log_success "Configuration validated." +} + +generate_passwords() { + if [ -z "$ADMIN_PASS" ]; then + ADMIN_PASS=$(generate_password) + log_info "Generated admin password." + fi + if [ -z "$POSTGRES_PASSWORD" ]; then + POSTGRES_PASSWORD=$(generate_password) + log_info "Generated PostgreSQL password." + fi + if [ -z "$CLICKHOUSE_PASSWORD" ]; then + CLICKHOUSE_PASSWORD=$(generate_password) + log_info "Generated ClickHouse password." + fi + if [ "$VENDOR_ENABLED" = "true" ] && [ -z "$VENDOR_PASS" ]; then + VENDOR_PASS=$(generate_password) + log_info "Generated vendor password." + fi +} +``` + +- [ ] **Step 2: Verify syntax** + +```bash +bash -n installer/install.sh +``` + +- [ ] **Step 3: Commit** + +```bash +git add installer/install.sh +git commit -m "feat(installer): add config merge, validation, and password generation" +``` + +--- + +### Task 13: File generation (.env, docker-compose.yml, certs) + +**Files:** +- Modify: `installer/install.sh` + +- [ ] **Step 1: Add cert copy and .env generation** + +Append: + +```bash +# --- File generation --- + +copy_certs() { + local certs_dir="$INSTALL_DIR/certs" + mkdir -p "$certs_dir" + cp "$CERT_FILE" "$certs_dir/cert.pem" + cp "$KEY_FILE" "$certs_dir/key.pem" + if [ -n "$CA_FILE" ]; then + cp "$CA_FILE" "$certs_dir/ca.pem" + fi + log_info "Copied TLS certificates to $certs_dir/" +} + +generate_env_file() { + local f="$INSTALL_DIR/.env" + cat > "$f" << EOF +# Cameleer SaaS Configuration +# Generated by installer v${CAMELEER_INSTALLER_VERSION} on $(date -u '+%Y-%m-%d %H:%M:%S UTC') + +# Image version +VERSION=${VERSION} + +# Public access +PUBLIC_HOST=${PUBLIC_HOST} +PUBLIC_PROTOCOL=${PUBLIC_PROTOCOL} + +# Ports +HTTP_PORT=${HTTP_PORT} +HTTPS_PORT=${HTTPS_PORT} +LOGTO_CONSOLE_PORT=${LOGTO_CONSOLE_PORT} + +# PostgreSQL +POSTGRES_USER=cameleer +POSTGRES_PASSWORD=${POSTGRES_PASSWORD} +POSTGRES_DB=cameleer_saas + +# ClickHouse +CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD} + +# Admin user +SAAS_ADMIN_USER=${ADMIN_USER} +SAAS_ADMIN_PASS=${ADMIN_PASS} + +# TLS +NODE_TLS_REJECT=${NODE_TLS_REJECT} +EOF + + if [ "$TLS_MODE" = "custom" ]; then + cat >> "$f" << 'EOF' +CERT_FILE=/user-certs/cert.pem +KEY_FILE=/user-certs/key.pem +EOF + if [ -n "$CA_FILE" ]; then + echo "CA_FILE=/user-certs/ca.pem" >> "$f" + fi + fi + + cat >> "$f" << EOF + +# Vendor account +VENDOR_SEED_ENABLED=${VENDOR_ENABLED} +VENDOR_USER=${VENDOR_USER} +VENDOR_PASS=${VENDOR_PASS:-} + +# Docker +DOCKER_SOCKET=${DOCKER_SOCKET} + +# Provisioning images +CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=${REGISTRY}/cameleer3-server:${VERSION} +CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=${REGISTRY}/cameleer3-server-ui:${VERSION} +EOF + + log_info "Generated .env" + cp "$f" "$INSTALL_DIR/.env.bak" +} +``` + +- [ ] **Step 2: Add docker-compose.yml generation** + +Append: + +```bash +generate_compose_file() { + local f="$INSTALL_DIR/docker-compose.yml" + : > "$f" + + # --- Header --- + cat >> "$f" << 'EOF' +# Cameleer SaaS Platform +# Generated by Cameleer installer — do not edit manually + +services: + traefik: + image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest} + restart: unless-stopped + ports: + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" +EOF + + # Logto console port (conditional) + if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then + cat >> "$f" << 'EOF' + - "${LOGTO_CONSOLE_PORT:-3002}:3002" +EOF + fi + + # Traefik environment + cat >> "$f" << 'EOF' + environment: + PUBLIC_HOST: ${PUBLIC_HOST:-localhost} + CERT_FILE: ${CERT_FILE:-} + KEY_FILE: ${KEY_FILE:-} + CA_FILE: ${CA_FILE:-} + volumes: + - certs:/certs + - ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro +EOF + + # Custom cert bind mount + if [ "$TLS_MODE" = "custom" ]; then + cat >> "$f" << 'EOF' + - ./certs:/user-certs:ro +EOF + fi + + # Traefik networks + cat >> "$f" << 'EOF' + networks: + - cameleer + - cameleer-traefik +EOF + + # Monitoring network for traefik + if [ -n "$MONITORING_NETWORK" ]; then + echo " - ${MONITORING_NETWORK}" >> "$f" + fi + + # Monitoring labels for traefik + if [ -n "$MONITORING_NETWORK" ]; then + cat >> "$f" << 'EOF' + labels: + - "prometheus.io/scrape=true" + - "prometheus.io/port=8082" + - "prometheus.io/path=/metrics" +EOF + fi + + # --- PostgreSQL --- + cat >> "$f" << 'EOF' + + postgres: + image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest} + restart: unless-stopped + environment: + POSTGRES_DB: cameleer_saas + POSTGRES_USER: ${POSTGRES_USER:-cameleer} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d cameleer_saas"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - cameleer +EOF + + if [ -n "$MONITORING_NETWORK" ]; then + echo " - ${MONITORING_NETWORK}" >> "$f" + fi + + # --- ClickHouse --- + cat >> "$f" << 'EOF' + + clickhouse: + image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest} + restart: unless-stopped + environment: + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} + volumes: + - chdata:/var/lib/clickhouse + healthcheck: + test: ["CMD-SHELL", "clickhouse-client --password $${CLICKHOUSE_PASSWORD} --query 'SELECT 1'"] + interval: 10s + timeout: 5s + retries: 3 + networks: + - cameleer +EOF + + if [ -n "$MONITORING_NETWORK" ]; then + echo " - ${MONITORING_NETWORK}" >> "$f" + cat >> "$f" << 'EOF' + labels: + - "prometheus.io/scrape=true" + - "prometheus.io/port=9363" + - "prometheus.io/path=/metrics" +EOF + fi + + # --- Logto --- + cat >> "$f" << 'EOF' + + logto: + image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest} + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD}@postgres:5432/logto + ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} + ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002} + TRUST_PROXY_HEADER: 1 + NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}" + LOGTO_ENDPOINT: http://logto:3001 + LOGTO_ADMIN_ENDPOINT: http://logto:3002 + LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} + PUBLIC_HOST: ${PUBLIC_HOST:-localhost} + PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https} + PG_HOST: postgres + PG_USER: ${POSTGRES_USER:-cameleer} + PG_PASSWORD: ${POSTGRES_PASSWORD} + PG_DB_SAAS: cameleer_saas + SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin} + SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:-admin} + VENDOR_SEED_ENABLED: "${VENDOR_SEED_ENABLED:-false}" + VENDOR_USER: ${VENDOR_USER:-vendor} + VENDOR_PASS: ${VENDOR_PASS:-vendor} + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"] + interval: 10s + timeout: 5s + retries: 60 + start_period: 30s + labels: + - traefik.enable=true + - traefik.http.routers.logto.rule=PathPrefix(`/`) + - traefik.http.routers.logto.priority=1 + - traefik.http.routers.logto.entrypoints=websecure + - traefik.http.routers.logto.tls=true + - traefik.http.routers.logto.service=logto + - traefik.http.routers.logto.middlewares=logto-cors + - "traefik.http.middlewares.logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}" + - traefik.http.middlewares.logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS + - traefik.http.middlewares.logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type + - traefik.http.middlewares.logto-cors.headers.accessControlAllowCredentials=true + - traefik.http.services.logto.loadbalancer.server.port=3001 +EOF + + # Logto console labels (conditional) + if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then + cat >> "$f" << 'EOF' + - traefik.http.routers.logto-console.rule=PathPrefix(`/`) + - traefik.http.routers.logto-console.entrypoints=admin-console + - traefik.http.routers.logto-console.tls=true + - traefik.http.routers.logto-console.service=logto-console + - traefik.http.services.logto-console.loadbalancer.server.port=3002 +EOF + fi + + cat >> "$f" << 'EOF' + volumes: + - bootstrapdata:/data + networks: + - cameleer +EOF + + # --- Cameleer SaaS --- + cat >> "$f" << 'EOF' + + cameleer-saas: + image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest} + restart: unless-stopped + depends_on: + logto: + condition: service_healthy + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/cameleer_saas + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: http://logto:3001 + CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} + CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https} + CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost} + CAMELEER_SAAS_PROVISIONING_NETWORKNAME: ${COMPOSE_PROJECT_NAME:-cameleer-saas}_cameleer + CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK: cameleer-traefik + CAMELEER_SAAS_PROVISIONING_SERVERIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:-gitea.siegeln.net/cameleer/cameleer3-server:latest} + CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:-gitea.siegeln.net/cameleer/cameleer3-server-ui:latest} + labels: + - traefik.enable=true + - traefik.http.routers.saas.rule=PathPrefix(`/platform`) + - traefik.http.routers.saas.entrypoints=websecure + - traefik.http.routers.saas.tls=true + - traefik.http.services.saas.loadbalancer.server.port=8080 +EOF + + # Monitoring labels for SaaS + if [ -n "$MONITORING_NETWORK" ]; then + cat >> "$f" << 'EOF' + - "prometheus.io/scrape=true" + - "prometheus.io/port=8080" + - "prometheus.io/path=/platform/actuator/prometheus" +EOF + fi + + cat >> "$f" << 'EOF' + volumes: + - bootstrapdata:/data/bootstrap:ro + - certs:/certs + - ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock + networks: + - cameleer +EOF + + if [ -n "$MONITORING_NETWORK" ]; then + echo " - ${MONITORING_NETWORK}" >> "$f" + fi + + cat >> "$f" << 'EOF' + group_add: + - "0" +EOF + + # --- Volumes --- + cat >> "$f" << 'EOF' + +volumes: + pgdata: + chdata: + certs: + bootstrapdata: +EOF + + # --- Networks --- + cat >> "$f" << 'EOF' + +networks: + cameleer: + driver: bridge + cameleer-traefik: + name: cameleer-traefik + driver: bridge +EOF + + # Monitoring network (external) + if [ -n "$MONITORING_NETWORK" ]; then + cat >> "$f" << EOF + ${MONITORING_NETWORK}: + external: true +EOF + fi + + log_info "Generated docker-compose.yml" +} +``` + +- [ ] **Step 3: Verify syntax** + +```bash +bash -n installer/install.sh +``` + +- [ ] **Step 4: Commit** + +```bash +git add installer/install.sh +git commit -m "feat(installer): add .env and docker-compose.yml generation" +``` + +--- + +### Task 14: Docker operations and health verification + +**Files:** +- Modify: `installer/install.sh` + +- [ ] **Step 1: Add docker operations and health checks** + +Append: + +```bash +# --- Docker operations --- + +docker_compose_pull() { + log_info "Pulling Docker images..." + (cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" pull) + log_success "All images pulled." +} + +docker_compose_up() { + log_info "Starting Cameleer SaaS platform..." + (cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" up -d) + log_info "Containers started." +} + +docker_compose_down() { + log_info "Stopping Cameleer SaaS platform..." + (cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" down) +} + +# --- Health verification --- + +check_service_health() { + local name="$1" check_cmd="$2" timeout_secs="${3:-120}" + local elapsed=0 + local start_time=$(date +%s) + + while [ $elapsed -lt $timeout_secs ]; do + if eval "$check_cmd" >/dev/null 2>&1; then + local duration=$(($(date +%s) - start_time)) + printf " ${GREEN}[ok]${NC} %-20s ready (%ds)\n" "$name" "$duration" + return 0 + fi + sleep 5 + elapsed=$(( $(date +%s) - start_time )) + done + + printf " ${RED}[FAIL]${NC} %-20s not ready after %ds\n" "$name" "$timeout_secs" + echo " Check: docker compose -p $COMPOSE_PROJECT logs ${name,,}" + return 1 +} + +verify_health() { + echo "" + log_info "Verifying installation..." + local failed=0 + + check_service_health "PostgreSQL" \ + "cd '$INSTALL_DIR' && docker compose -p '$COMPOSE_PROJECT' exec -T postgres pg_isready -U cameleer" \ + 120 || failed=1 + + [ $failed -eq 0 ] && check_service_health "ClickHouse" \ + "cd '$INSTALL_DIR' && docker compose -p '$COMPOSE_PROJECT' exec -T clickhouse clickhouse-client --password \"\$(grep CLICKHOUSE_PASSWORD '$INSTALL_DIR/.env' | cut -d= -f2)\" --query 'SELECT 1'" \ + 120 || failed=1 + + [ $failed -eq 0 ] && check_service_health "Logto" \ + "cd '$INSTALL_DIR' && docker compose -p '$COMPOSE_PROJECT' 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))\"" \ + 120 || failed=1 + + [ $failed -eq 0 ] && check_service_health "Bootstrap" \ + "cd '$INSTALL_DIR' && docker compose -p '$COMPOSE_PROJECT' exec -T logto test -f /data/logto-bootstrap.json" \ + 120 || failed=1 + + [ $failed -eq 0 ] && check_service_health "Cameleer SaaS" \ + "curl -sfk https://localhost:${HTTPS_PORT}/platform/api/config" \ + 120 || failed=1 + + [ $failed -eq 0 ] && check_service_health "Traefik routing" \ + "curl -sfk -o /dev/null https://localhost:${HTTPS_PORT}/" \ + 120 || failed=1 + + echo "" + if [ $failed -ne 0 ]; then + log_error "Installation verification failed. Stack is running — check logs." + exit 1 + fi + log_success "All services healthy." +} +``` + +- [ ] **Step 2: Verify syntax** + +```bash +bash -n installer/install.sh +``` + +- [ ] **Step 3: Commit** + +```bash +git add installer/install.sh +git commit -m "feat(installer): add docker operations and health verification" +``` + +--- + +### Task 15: Output file generation + +**Files:** +- Modify: `installer/install.sh` + +- [ ] **Step 1: Add credentials, INSTALL.md, and config file generation** + +Append: + +```bash +# --- Output file generation --- + +write_config_file() { + local f="$INSTALL_DIR/cameleer.conf" + cat > "$f" << EOF +# Cameleer installation config +# Generated by installer v${CAMELEER_INSTALLER_VERSION} on $(date -u '+%Y-%m-%d %H:%M:%S UTC') + +install_dir=${INSTALL_DIR} +public_host=${PUBLIC_HOST} +public_protocol=${PUBLIC_PROTOCOL} +admin_user=${ADMIN_USER} +tls_mode=${TLS_MODE} +http_port=${HTTP_PORT} +https_port=${HTTPS_PORT} +logto_console_port=${LOGTO_CONSOLE_PORT} +logto_console_exposed=${LOGTO_CONSOLE_EXPOSED} +vendor_enabled=${VENDOR_ENABLED} +vendor_user=${VENDOR_USER} +monitoring_network=${MONITORING_NETWORK} +version=${VERSION} +compose_project=${COMPOSE_PROJECT} +docker_socket=${DOCKER_SOCKET} +node_tls_reject=${NODE_TLS_REJECT} +EOF + log_info "Saved installer config to cameleer.conf" +} + +generate_credentials_file() { + local f="$INSTALL_DIR/credentials.txt" + cat > "$f" << EOF +=========================================== + CAMELEER PLATFORM CREDENTIALS + Generated: $(date -u '+%Y-%m-%d %H:%M:%S UTC') + + SECURE THIS FILE AND DELETE AFTER NOTING + THESE CREDENTIALS CANNOT BE RECOVERED +=========================================== + +Admin Console: ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/platform/ +Admin User: ${ADMIN_USER} +Admin Password: ${ADMIN_PASS} + +PostgreSQL: cameleer / ${POSTGRES_PASSWORD} +ClickHouse: default / ${CLICKHOUSE_PASSWORD} + +EOF + + if [ "$VENDOR_ENABLED" = "true" ]; then + cat >> "$f" << EOF +Vendor User: ${VENDOR_USER} +Vendor Password: ${VENDOR_PASS} + +EOF + else + echo "Vendor User: (not enabled)" >> "$f" + echo "" >> "$f" + fi + + if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then + echo "Logto Console: ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}:${LOGTO_CONSOLE_PORT}" >> "$f" + else + echo "Logto Console: (not exposed)" >> "$f" + fi + + chmod 600 "$f" + log_info "Saved credentials to credentials.txt" +} + +generate_install_doc() { + local f="$INSTALL_DIR/INSTALL.md" + local tls_desc="Self-signed (auto-generated)" + [ "$TLS_MODE" = "custom" ] && tls_desc="Custom certificate" + + cat > "$f" << EOF +# Cameleer SaaS — Installation Documentation + +## Installation Summary + +| | | +|---|---| +| **Version** | ${VERSION} | +| **Date** | $(date -u '+%Y-%m-%d %H:%M:%S UTC') | +| **Installer** | v${CAMELEER_INSTALLER_VERSION} | +| **Install Directory** | ${INSTALL_DIR} | +| **Hostname** | ${PUBLIC_HOST} | +| **TLS** | ${tls_desc} | + +## Service URLs + +- **Platform UI:** ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/platform/ +- **API Endpoint:** ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/platform/api/ +EOF + + if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then + echo "- **Logto Admin Console:** ${PUBLIC_PROTOCOL}://${PUBLIC_HOST}:${LOGTO_CONSOLE_PORT}" >> "$f" + fi + + cat >> "$f" << 'EOF' + +## 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 + +EOF + + cat >> "$f" << EOF +| Port | Service | +|---|---| +| ${HTTP_PORT} | HTTP (redirects to HTTPS) | +| ${HTTPS_PORT} | HTTPS (main entry point) | +EOF + + if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then + echo "| ${LOGTO_CONSOLE_PORT} | Logto Admin Console |" >> "$f" + fi + + if [ -n "$MONITORING_NETWORK" ]; then + cat >> "$f" << EOF + +### Monitoring + +Services are connected to the \`${MONITORING_NETWORK}\` Docker network with Prometheus labels for auto-discovery. +EOF + fi + + cat >> "$f" << EOF + +## TLS + +**Mode:** ${tls_desc} +EOF + + if [ "$TLS_MODE" = "self-signed" ]; then + cat >> "$f" << 'EOF' + +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) +EOF + fi + + cat >> "$f" << 'EOF' + +## Data & Backups + +| Docker Volume | Contains | +|---|---| +| `pgdata` | PostgreSQL data (tenants, licenses, audit) | +| `chdata` | ClickHouse data (traces, metrics, logs) | +| `certs` | TLS certificates | +| `bootstrapdata` | Logto bootstrap results | + +### Backup Commands + +```bash +# PostgreSQL +docker compose exec postgres pg_dump -U cameleer cameleer_saas > backup.sql + +# ClickHouse +docker compose exec clickhouse clickhouse-client --query "SELECT * FROM cameleer.traces FORMAT Native" > traces.native +``` + +## Upgrading + +Re-run the installer with a new version: + +```bash +curl -sfL https://install.cameleer.io | bash -s -- --install-dir 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 logs SERVICE_NAME` | +| Bootstrap failed | `docker compose logs logto` | +| Routing issues | `docker compose logs traefik` | +| Database issues | `docker compose exec postgres psql -U cameleer -d cameleer_saas` | + +## Uninstalling + +```bash +# Stop and remove containers +docker compose down + +# Remove data volumes (DESTRUCTIVE) +docker compose down -v + +# Remove install directory +rm -rf INSTALL_DIR +``` +EOF + + # Replace INSTALL_DIR placeholder + sed -i "s|INSTALL_DIR|${INSTALL_DIR}|g" "$f" + + log_info "Generated INSTALL.md" +} + +print_credentials() { + echo "" + echo -e "${BOLD}==========================================${NC}" + echo -e "${BOLD} CAMELEER PLATFORM CREDENTIALS${NC}" + echo -e "${BOLD}==========================================${NC}" + echo "" + echo -e " Admin Console: ${BLUE}${PUBLIC_PROTOCOL}://${PUBLIC_HOST}/platform/${NC}" + echo -e " Admin User: ${BOLD}${ADMIN_USER}${NC}" + echo -e " Admin Password: ${BOLD}${ADMIN_PASS}${NC}" + echo "" + echo -e " PostgreSQL: cameleer / ${POSTGRES_PASSWORD}" + echo -e " ClickHouse: default / ${CLICKHOUSE_PASSWORD}" + echo "" + if [ "$VENDOR_ENABLED" = "true" ]; then + echo -e " Vendor User: ${BOLD}${VENDOR_USER}${NC}" + echo -e " Vendor Password: ${BOLD}${VENDOR_PASS}${NC}" + echo "" + fi + if [ "$LOGTO_CONSOLE_EXPOSED" = "true" ]; then + echo -e " Logto Console: ${BLUE}${PUBLIC_PROTOCOL}://${PUBLIC_HOST}:${LOGTO_CONSOLE_PORT}${NC}" + echo "" + fi + echo -e " Credentials saved to: ${INSTALL_DIR}/credentials.txt" + echo -e " ${YELLOW}Secure this file and delete after noting credentials.${NC}" + echo "" +} + +print_summary() { + echo -e "${GREEN}==========================================${NC}" + echo -e "${GREEN} Installation complete!${NC}" + echo -e "${GREEN}==========================================${NC}" + echo "" + echo " Install directory: $INSTALL_DIR" + echo " Documentation: $INSTALL_DIR/INSTALL.md" + echo "" + echo " To manage the stack:" + echo " cd $INSTALL_DIR" + echo " docker compose ps # status" + echo " docker compose logs -f # logs" + echo " docker compose down # stop" + echo "" +} +``` + +- [ ] **Step 2: Verify syntax** + +```bash +bash -n installer/install.sh +``` + +- [ ] **Step 3: Commit** + +```bash +git add installer/install.sh +git commit -m "feat(installer): add output file generation (credentials, INSTALL.md, config)" +``` + +--- + +### Task 16: Re-run and upgrade logic + +**Files:** +- Modify: `installer/install.sh` + +- [ ] **Step 1: Add re-run menu and handlers** + +Append: + +```bash +# --- Re-run and upgrade --- + +show_rerun_menu() { + # Read existing version from config + local current_version + current_version=$(grep '^version=' "$INSTALL_DIR/cameleer.conf" 2>/dev/null | cut -d= -f2 || echo "unknown") + local current_host + current_host=$(grep '^public_host=' "$INSTALL_DIR/cameleer.conf" 2>/dev/null | cut -d= -f2 || echo "unknown") + + echo "" + echo -e "${BOLD}Existing Cameleer installation detected (v${current_version})${NC}" + echo " Install directory: $INSTALL_DIR" + echo " Public host: $current_host" + echo "" + + # In silent mode, default to upgrade + if [ "$MODE" = "silent" ]; then + RERUN_ACTION="${RERUN_ACTION:-upgrade}" + return + fi + + # If --reconfigure or --reinstall was passed, use it + if [ -n "$RERUN_ACTION" ]; then return; fi + + local new_version="${VERSION:-$CAMELEER_DEFAULT_VERSION}" + echo " [1] Upgrade to v${new_version} (pull new images, update compose)" + echo " [2] Reconfigure (re-run interactive setup, preserve data)" + echo " [3] Reinstall (fresh install, WARNING: destroys data volumes)" + echo " [4] Cancel" + echo "" + + local choice + read -rp " Select [1]: " choice + case "${choice:-1}" in + 1) RERUN_ACTION="upgrade" ;; + 2) RERUN_ACTION="reconfigure" ;; + 3) RERUN_ACTION="reinstall" ;; + 4) echo "Cancelled."; exit 0 ;; + *) echo "Invalid choice."; exit 1 ;; + esac +} + +handle_rerun() { + case "$RERUN_ACTION" in + upgrade) + log_info "Upgrading installation..." + # Load existing config, keep everything + load_config_file "$INSTALL_DIR/cameleer.conf" + load_env_overrides + merge_config + # Regenerate compose (may have template updates) but keep .env + generate_compose_file + docker_compose_pull + docker_compose_down + docker_compose_up + verify_health + generate_install_doc + print_summary + exit 0 + ;; + reconfigure) + log_info "Reconfiguring installation..." + # Reset config values so prompts offer current values as defaults + # (values are already loaded from cameleer.conf by detect_existing_install) + return # continue with normal flow + ;; + reinstall) + if [ "${CONFIRM_DESTROY:-false}" != "true" ]; then + echo "" + log_warn "This will destroy ALL data (databases, certificates, bootstrap)." + if ! prompt_yesno "Are you sure? This cannot be undone."; then + echo "Cancelled." + exit 0 + fi + fi + log_info "Reinstalling..." + docker_compose_down + (cd "$INSTALL_DIR" && docker compose -p "$COMPOSE_PROJECT" down -v 2>/dev/null || true) + rm -f "$INSTALL_DIR/.env" "$INSTALL_DIR/docker-compose.yml" \ + "$INSTALL_DIR/cameleer.conf" "$INSTALL_DIR/credentials.txt" \ + "$INSTALL_DIR/INSTALL.md" "$INSTALL_DIR/.env.bak" + rm -rf "$INSTALL_DIR/certs" + IS_RERUN=false + return # continue with fresh install flow + ;; + esac +} +``` + +- [ ] **Step 2: Verify syntax** + +```bash +bash -n installer/install.sh +``` + +- [ ] **Step 3: Commit** + +```bash +git add installer/install.sh +git commit -m "feat(installer): add re-run, upgrade, and reinstall logic" +``` + +--- + +### Task 17: Main function and end-to-end testing + +**Files:** +- Modify: `installer/install.sh` + +- [ ] **Step 1: Add main function** + +Append: + +```bash +# --- Main --- + +main() { + parse_args "$@" + + print_banner + + # Load config sources (CLI already loaded via parse_args) + if [ -n "$CONFIG_FILE_PATH" ]; then + load_config_file "$CONFIG_FILE_PATH" + fi + load_env_overrides + + # Check for existing installation + detect_existing_install + if [ "$IS_RERUN" = true ]; then + show_rerun_menu + handle_rerun + fi + + # Prerequisites + check_prerequisites + + # Auto-detect defaults + auto_detect + + # Interactive prompts (unless silent) + if [ "$MODE" != "silent" ]; then + select_mode + if [ "$MODE" = "expert" ]; then + run_expert_prompts + else + run_simple_prompts + fi + fi + + # Merge remaining defaults and validate + merge_config + validate_config + + # Generate passwords for any empty values + generate_passwords + + # Create install directory + mkdir -p "$INSTALL_DIR" + + # Copy custom certs if provided + if [ "$TLS_MODE" = "custom" ]; then + copy_certs + fi + + # Generate configuration files + generate_env_file + generate_compose_file + write_config_file + + # Pull and start + docker_compose_pull + docker_compose_up + + # Verify health + verify_health + + # Generate output files + generate_credentials_file + generate_install_doc + + # Print results + print_credentials + print_summary +} + +main "$@" +``` + +- [ ] **Step 2: Verify the complete script has no syntax errors** + +```bash +bash -n installer/install.sh +``` + +- [ ] **Step 3: Test silent mode with defaults (dry-run inspection)** + +Create a test that generates files without actually pulling/starting: + +```bash +# Temporarily disable docker operations for testing +bash -c ' + source <(sed "s/docker_compose_pull/# &/;s/docker_compose_up/# &/;s/verify_health/# &/" installer/install.sh | head -n -2) + INSTALL_DIR="/tmp/cameleer-test" + MODE="silent" + merge_config + generate_passwords + mkdir -p "$INSTALL_DIR" + generate_env_file + generate_compose_file + write_config_file + generate_credentials_file + echo "=== Generated .env ===" + cat "$INSTALL_DIR/.env" + echo "" + echo "=== Generated compose (first 20 lines) ===" + head -20 "$INSTALL_DIR/docker-compose.yml" + rm -rf /tmp/cameleer-test +' +``` + +Expected: .env and docker-compose.yml generated with default values. + +- [ ] **Step 4: Full end-to-end test (requires Phase 1 complete and images available)** + +```bash +bash installer/install.sh --silent --install-dir /tmp/cameleer-e2e --public-host localhost +# Wait for health checks to pass +# Verify: curl -sk https://localhost/platform/api/config +# Cleanup: cd /tmp/cameleer-e2e && docker compose down -v && rm -rf /tmp/cameleer-e2e +``` + +- [ ] **Step 5: Commit** + +```bash +git add installer/install.sh +git commit -m "feat(installer): add main function and complete install.sh + +Fully functional bash installer with simple/expert/silent modes, +idempotent re-run, health verification, and documentation generation." +``` + +--- + +## Phase 3: PowerShell Installer + +### Task 18: PowerShell implementation + +**Files:** +- Create: `installer/install.ps1` + +The PowerShell script mirrors the bash installer's structure and logic exactly. It produces identical output files (docker-compose.yml, .env, credentials.txt, INSTALL.md, cameleer.conf). + +- [ ] **Step 1: Create install.ps1** + +Create `installer/install.ps1` with the same function structure as install.sh, adapted for PowerShell idioms: + +| Bash function | PowerShell equivalent | Key differences | +|---|---|---| +| `generate_password` | `New-SecurePassword` | Uses `[System.Security.Cryptography.RandomNumberGenerator]` | +| `parse_args` | `param()` block | PowerShell native parameter handling | +| `prompt` / `prompt_password` | `Read-Host` / `Read-Host -AsSecureString` | Native PowerShell prompts | +| `check_prerequisites` | `Test-Prerequisites` | Uses `Get-Command`, `Test-NetConnection` | +| `auto_detect` | `Get-AutoDetectedDefaults` | Uses `[System.Net.Dns]::GetHostEntry()` | +| `generate_compose_file` | `New-ComposeFile` | Uses here-strings (`@'...'@`) | +| `generate_env_file` | `New-EnvFile` | Same format, `Set-Content` | +| `docker_compose_pull/up` | `Invoke-DockerCompose` | Same docker commands | +| `verify_health` | `Test-ServiceHealth` | Same docker exec + curl checks | +| `check_port_available` | `Test-PortAvailable` | Uses `Test-NetConnection -Port` | + +Key structural differences for PowerShell: +- Parameters declared via `param()` block (replaces manual arg parsing) +- Docker socket default: `//./pipe/docker_engine` (Windows named pipe) +- Password generation: `[System.Security.Cryptography.RandomNumberGenerator]::GetBytes(24)` → Base64 +- Port checking: `Test-NetConnection -ComputerName localhost -Port $port` +- Hostname detection: `[System.Net.Dns]::GetHostEntry([System.Net.Dns]::GetHostName()).HostName` +- Color output: `Write-Host -ForegroundColor` instead of ANSI escape codes +- Here-strings for templates: `@'...'@` (literal) and `@"..."@` (expanding) + +The compose template content must be identical to the bash version — same YAML, same variable references, same conditional sections. Use the same `>> $file` append pattern with PowerShell here-strings. + +```powershell +#Requires -Version 5.1 +[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 +) + +$InstallerVersion = "1.0.0" +$DefaultVersion = "latest" +$Registry = "gitea.siegeln.net/cameleer" + +# ... implement all functions following the bash structure ... +# Each function mirrors its bash equivalent exactly. +# The generated files (.env, docker-compose.yml, etc.) must be identical. +``` + +Implement each function following the bash structure. The implementing agent should read `installer/install.sh` and translate each function to PowerShell, maintaining identical behavior and output. + +- [ ] **Step 2: Verify PowerShell syntax** + +```powershell +$null = [System.Management.Automation.PSParser]::Tokenize((Get-Content installer/install.ps1 -Raw), [ref]$null) +``` + +Or on Linux with PowerShell Core: + +```bash +pwsh -Command "& { \$null = [System.Management.Automation.PSParser]::Tokenize((Get-Content 'installer/install.ps1' -Raw), [ref]\$null); Write-Host 'Syntax OK' }" +``` + +- [ ] **Step 3: Commit** + +```bash +git add installer/install.ps1 +git commit -m "feat(installer): add PowerShell installer for Windows + +Mirrors install.sh structure and produces identical output files. +Uses native PowerShell idioms for parameters, prompts, and crypto." +``` + +--- + +## Task Dependencies + +``` +Tasks 1,2,3,4 ─── can run in parallel (independent images) + │ + Task 5 ─────── depends on 1-4 (compose update) + Task 6 ─────── depends on 1-4 (CI update) + │ + Task 7 ─────── depends on 5,6 (stack validation) + +Tasks 8-16 ────── can run in parallel with Phase 1 + (installer generates files, doesn't need images) + │ + Task 17 ────── depends on Task 7 + Task 16 + (E2E test needs working images + complete installer) + + Task 18 ────── depends on Task 16 + (PowerShell mirrors completed bash) +``` + +## Follow-up (out of scope) + +- Bake `docker/server-ui-entrypoint.sh` into the `cameleer3-server-ui` image (separate repo) +- Set up `install.cameleer.io` distribution endpoint +- Create release automation (tag → publish installer scripts to distribution endpoint) +- Add `docker-compose.dev.yml` overlay generation for the installer's expert mode