# 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}/cameleer-server:${VERSION} CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=${REGISTRY}/cameleer-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/cameleer-server:latest} CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:-gitea.siegeln.net/cameleer/cameleer-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 `cameleer-server` and `cameleer-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 `cameleer-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