diff --git a/README.md b/README.md index c9f0f71..44d1518 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Felhom App Catalog -Central repository for all Felhom customer application deployments. +Central repository for Felhom customer application templates. ## Architecture ``` -felhom-app-catalog/ ← This repo (source of truth) -├── templates/ # Docker Compose templates with placeholders +app-catalog-felhom.eu/ <- This repo (source of truth) +├── templates.json # Portainer App Templates index (generic, with env var prompts) +├── templates/ # Docker Compose templates with ${VAR} env var syntax │ ├── actualbudget/ │ ├── docmost/ │ ├── filebrowser/ @@ -17,121 +18,186 @@ felhom-app-catalog/ ← This repo (source of truth) │ ├── romm/ │ ├── stirling-pdf/ │ └── vaultwarden/ -├── customers/ # Per-customer configuration (YAML) -│ ├── demo-felhom.yaml -│ └── pi-customer-1.yaml -├── scripts/ -│ └── render.sh # Renders output from templates + customer configs -└── output/ # Generated monorepo (pushed to Gitea) - ├── README.md - ├── demo-felhom/ - │ ├── actualbudget/docker-compose.yml - │ ├── immich/docker-compose.yml - │ └── ... - └── pi-customer-1/ - ├── actualbudget/docker-compose.yml - └── ... +└── scripts/ + └── generate-customer.sh # Generates customer-specific templates with baked-in secrets ``` -The `output/` directory is what gets pushed to: +The output is pushed to: **https://gitea.dooplex.hu/admin/customers-felhom.eu** +``` +customers-felhom.eu/ <- Generated per-customer deployments +├── demo-felhom/ +│ ├── templates.json # Customer Portainer templates (zero env vars, zero-touch deploy) +│ ├── secrets.env # Reference copy of all generated secrets +│ ├── actualbudget/docker-compose.yml +│ ├── docmost/docker-compose.yml +│ └── ... +└── pi-customer-1/ + ├── templates.json + ├── secrets.env + ├── actualbudget/docker-compose.yml + └── ... +``` + ## How It Works -1. **Templates** contain Docker Compose files with `{{DOMAIN}}` and `{{HDD_PATH}}` placeholders -2. **Customer configs** define which apps each customer gets, their domain, HDD path, and any version overrides -3. **render.sh** substitutes all placeholders and generates the output directory -4. **`--push`** commits and pushes the output to the Gitea monorepo -5. **Portainer GitOps** on each customer node pulls from the same repo, using a different compose path per stack - -### Placeholder Reference - -| Placeholder | Source | Example | -|-------------|--------|---------| -| `{{DOMAIN}}` | `domain:` in customer YAML | `demo-felhom.eu` | -| `{{HDD_PATH}}` | `hdd_path:` in customer YAML | `/mnt/hdd_1` | - -### Storage Strategy - -- **HDD host paths** (`{{HDD_PATH}}/storage/...`): Large user data — photos, documents, ROMs -- **Named Docker volumes** (on NVMe): Databases, app config, caches — need fast I/O -- Templates that don't use `{{HDD_PATH}}` work without it (e.g. ActualBudget, Mealie) -- If a template needs `{{HDD_PATH}}` but the customer config doesn't set `hdd_path:`, the render script refuses that app and tells you what to fix +1. **Templates** in this repo contain Docker Compose files with `${DOMAIN}`, `${HDD_PATH}`, and `${SECRET}` placeholders +2. **`generate-customer.sh`** substitutes all values (domain, HDD path, auto-generated passwords) and produces customer-specific compose files + a Portainer `templates.json` with zero env vars +3. **`docker-setup.sh`** on the customer node starts Portainer with `--templates` pointing at the customer's generated `templates.json` +4. **Customer** opens Portainer -> Templates -> picks an app -> clicks Deploy -> **done** ## Workflow -### Render & push +### 1. Generate customer templates (on your workstation) ```bash -./scripts/render.sh # Render all customers locally -./scripts/render.sh --push # Render + commit + push to Gitea -./scripts/render.sh --customer demo-felhom # Render one customer only -./scripts/render.sh --dry-run # Preview what would happen -./scripts/render.sh --debug # Verbose output +# Full setup: all apps, with HDD +./scripts/generate-customer.sh --customer demo-felhom \ + --domain demo-felhom.eu --hdd-path /mnt/hdd_1 --push + +# Raspberry Pi: lightweight apps only, no HDD yet +./scripts/generate-customer.sh --customer pi-customer-1 \ + --domain pi-customer-1.local \ + --apps actualbudget,filebrowser,mealie,stirling-pdf,vaultwarden --push + +# Preview without creating files +./scripts/generate-customer.sh --customer test --domain test.local --dry-run ``` -The default Gitea repo URL is `https://gitea.dooplex.hu/admin/customers-felhom.eu.git`. -Override with: `GITEA_REPO_URL=https://... ./scripts/render.sh --push` +Options: +- `--customer ID` -- Customer identifier (required) +- `--domain DOMAIN` -- Customer domain (required) +- `--hdd-path PATH` -- External HDD mount path (optional; apps needing it show a field in Portainer if omitted) +- `--apps LIST` -- Comma-separated app list (default: all available) +- `--push` -- Git commit and push to customers-felhom.eu repo +- `--dry-run` -- Show what would be done +- `--debug` -- Verbose output -### Adding a new app to the catalog +### 2. Set up customer server -1. Create `templates//docker-compose.yml` using `{{DOMAIN}}` and optionally `{{HDD_PATH}}` -2. Add the app name to relevant customer configs in `customers/` -3. Run `./scripts/render.sh --push` - -### Updating an app version - -1. Edit the image tag in `templates//docker-compose.yml` -2. Run `./scripts/render.sh --push` -3. Portainer auto-detects git changes and redeploys (if polling enabled) - -Customers with version overrides keep their pinned version. - -### Adding a new customer - -1. Create `customers/.yaml` (copy an existing one as template) -2. Run `./scripts/render.sh --push` -3. Set up Portainer GitOps stacks on the customer node (see below) - -## Portainer Stack Setup (per app) - -On the customer's Portainer, for each app: - -1. **Stacks → Add Stack → Repository** -2. Repository URL: `https://gitea.dooplex.hu/admin/customers-felhom.eu` -3. Compose path: `//docker-compose.yml` - - Example: `demo-felhom/immich/docker-compose.yml` -4. Add environment variables (secrets — DB passwords, API keys, etc.) -5. Enable **GitOps auto-update** (optional, 5-minute polling) -6. Deploy - -## Environment Variables - -Secrets are **never stored in Git**. They live in Portainer's stack environment -variables on each customer node. Each template documents required env vars -in comments at the top of the compose file. - -## Version Pinning - -In a customer YAML, you can pin specific app versions: - -```yaml -overrides: - immich_version: "v2.4.1" # Don't auto-update Immich for this customer - auto_update: false # Skip ALL updates for this customer +```bash +sudo ./docker-setup.sh --domain demo-felhom.eu --customer demo-felhom \ + --email certs@felhom.eu --cf-token ``` +### 3. Deploy apps + +Open `https://portainer.` -> **Templates** -> pick an app -> **Deploy the stack**. + +All fields (domain, passwords, secrets) are pre-filled. If HDD_PATH wasn't set during generation, apps that need it will show one field to fill in. + +### Re-generating after changes + +Re-running `generate-customer.sh` for an existing customer preserves all previously generated secrets (idempotent). Only new apps or new secrets are generated. + +```bash +# Add an app to an existing customer +./scripts/generate-customer.sh --customer demo-felhom \ + --domain demo-felhom.eu --hdd-path /mnt/hdd_1 --push +``` + +## Environment Variables & Secrets + +Secrets are auto-generated by `generate-customer.sh` and baked directly into the compose files and Portainer templates. They are **not** stored separately on the customer node -- they live in the customers-felhom.eu Git repo (which is private on your Gitea). + +A `secrets.env` reference file is generated alongside the compose files for easy lookup. + +### Secret definitions + +Defined in `generate-customer.sh` via the `APP_SECRET_DEFS` array: + +```bash +APP_SECRET_DEFS=( + "docmost:APP_SECRET:hex:32" + "docmost:DB_PASSWORD:password:24" + "immich:DB_PASSWORD:password:24" + "paperless-ngx:PAPERLESS_SECRET_KEY:hex:32" + "paperless-ngx:DB_PASSWORD:password:24" + "paperless-ngx:PAPERLESS_ADMIN_USER:static:admin" + "paperless-ngx:PAPERLESS_ADMIN_PASSWORD:password:16" + "romm:DB_PASSWORD:password:24" + "romm:MYSQL_ROOT_PASSWORD:password:24" + "romm:ROMM_AUTH_SECRET_KEY:hex:32" + "vaultwarden:ADMIN_TOKEN:hex:32" + "vaultwarden:SIGNUPS_ALLOWED:static:true" +) +``` + +Types: `password:LENGTH` (alphanumeric), `hex:LENGTH` (crypto), `static:VALUE` (fixed). + +### Variable types per app + +| App | DOMAIN | HDD_PATH | Secrets | +|-----|:------:|:--------:|---------| +| ActualBudget | yes | -- | -- | +| Docmost | yes | -- | APP_SECRET, DB_PASSWORD | +| FileBrowser | yes | yes | -- | +| Homebox | yes | -- | -- | +| Immich | yes | yes | DB_PASSWORD | +| Mealie | yes | -- | -- | +| Paperless-ngx | yes | yes | PAPERLESS_SECRET_KEY, DB_PASSWORD, PAPERLESS_ADMIN_USER, PAPERLESS_ADMIN_PASSWORD | +| ROMM | yes | yes | DB_PASSWORD, MYSQL_ROOT_PASSWORD, ROMM_AUTH_SECRET_KEY | +| Stirling-PDF | yes | -- | -- | +| Vaultwarden | yes | -- | ADMIN_TOKEN | + ## App Catalog | App | DB Type | RAM | Pi | HDD Data | Subdomain | |-----|---------|-----|-----|----------|-----------| -| ActualBudget | None (file) | ~50MB | ✅ | — | budget.* | -| Docmost | PostgreSQL + Redis | ~200MB | ⚠️ | — | docs.* | -| FileBrowser | None (file) | ~30MB | ✅ | `{{HDD_PATH}}/` | files.* | -| Homebox | None (SQLite) | ~50MB | ✅ | — | inventory.* | -| Immich | PostgreSQL + Redis | ~4GB | ❌ | `{{HDD_PATH}}/storage/immich/` | photos.* | -| Mealie | None (SQLite) | ~200MB | ✅ | — | recipes.* | -| Paperless-ngx | PostgreSQL + Redis | ~500MB | ✅ | `{{HDD_PATH}}/storage/paperless/` | paperless.* | -| ROMM | MariaDB + Redis | ~300MB | ⚠️ | `{{HDD_PATH}}/storage/romm/` | arcade.* | -| Stirling-PDF | None | ~200MB | ✅ | — | pdf.* | -| Vaultwarden | None (SQLite) | ~50MB | ✅ | — | vault.* | +| ActualBudget | None (file) | ~50MB | yes | -- | budget.* | +| Docmost | PostgreSQL + Redis | ~200MB | maybe | -- | docs.* | +| FileBrowser | None (file) | ~30MB | yes | `${HDD_PATH}/storage/filebrowser/` | files.* | +| Homebox | None (SQLite) | ~50MB | yes | -- | inventory.* | +| Immich | PostgreSQL + Redis | ~4GB | no | `${HDD_PATH}/storage/immich/` | photos.* | +| Mealie | None (SQLite) | ~200MB | yes | -- | recipes.* | +| Paperless-ngx | PostgreSQL + Redis | ~500MB | yes | `${HDD_PATH}/storage/paperless/` | paperless.* | +| ROMM | MariaDB + Redis | ~300MB | maybe | `${HDD_PATH}/storage/romm/` | arcade.* | +| Stirling-PDF | None | ~200MB | yes | -- | pdf.* | +| Vaultwarden | None (SQLite) | ~50MB | yes | -- | vault.* | + +### Storage strategy + +- **HDD host paths** (`${HDD_PATH}/storage/...`): Large user data -- photos, documents, ROMs +- **Named Docker volumes** (on internal SSD): Databases, app config, caches -- need fast I/O +- Templates without `${HDD_PATH}` work without an external HDD (e.g., ActualBudget, Mealie) + +## Adding a New App + +1. Create `templates//docker-compose.yml` using `${DOMAIN}` and optionally `${HDD_PATH}` +2. Add a template entry in `templates.json` with env definitions, description, logo, and notes +3. If the app needs secrets, add entries to `APP_SECRET_DEFS` in `generate-customer.sh` +4. Re-run `generate-customer.sh --push` for each customer + +### templates.json entry format + +```json +{ + "type": 3, + "title": "App Name", + "description": "Short description.", + "categories": ["category"], + "platform": "linux", + "logo": "https://example.com/logo.png", + "note": "Access: https://subdomain.<DOMAIN>", + "repository": { + "url": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu", + "stackfile": "templates/appname/docker-compose.yml" + }, + "env": [ + { + "name": "DOMAIN", + "label": "Domain", + "description": "Your server domain (e.g., demo-felhom.eu)" + } + ] +} +``` + +## Related Repositories + +| Repository | Purpose | +|------------|---------| +| [app-catalog-felhom.eu](https://gitea.dooplex.hu/admin/app-catalog-felhom.eu) | This repo -- templates + generation script | +| [customers-felhom.eu](https://gitea.dooplex.hu/admin/customers-felhom.eu) | Generated per-customer compose files + templates | +| [deploy-portainer](https://gitea.dooplex.hu/admin/deploy-portainer) | `docker-setup.sh` -- server provisioning | \ No newline at end of file diff --git a/scripts/generate-customer.sh b/scripts/generate-customer.sh new file mode 100644 index 0000000..b3de869 --- /dev/null +++ b/scripts/generate-customer.sh @@ -0,0 +1,658 @@ +#!/usr/bin/env bash +#=============================================================================== +# generate-customer.sh — Generate customer-specific Portainer templates +# +# Creates a customer directory with: +# - templates.json Portainer App Templates (secrets as defaults, zero-touch deploy) +# - /docker-compose.yml Compose files with domain + secrets baked in +# - secrets.env Reference file with all generated secrets +# +# Run from the app-catalog-felhom.eu repo root. +# +# Usage: +# ./scripts/generate-customer.sh --customer demo-felhom --domain demo-felhom.eu \ +# --hdd-path /mnt/hdd_1 --push +# +# Version: 1.0.0 +#=============================================================================== +set -euo pipefail + +SCRIPT_VERSION="1.0.0" + +#------------------------------------------------------------------------------- +# Configuration +#------------------------------------------------------------------------------- +CUSTOMERS_REPO_GIT="https://gitea.dooplex.hu/admin/customers-felhom.eu.git" +CUSTOMERS_REPO_RAW="https://gitea.dooplex.hu/admin/customers-felhom.eu" +OUTPUT_BASE="./output" + +# Defaults +CUSTOMER_ID="" +DOMAIN="" +HDD_PATH="" +APP_LIST="" # comma-separated, or empty for all +PUSH=false +DRY_RUN=false +DEBUG_MODE=false + +# Apps that require HDD_PATH in compose volumes +APPS_NEEDING_HDD="filebrowser immich paperless-ngx romm" + +#------------------------------------------------------------------------------- +# App secret definitions +# +# Format: "app_name:VAR_NAME:type:param" +# +# Types: +# password:LENGTH — alphanumeric random string (a-zA-Z0-9) +# hex:LENGTH — hex string (for crypto secrets) +# static:VALUE — fixed value +# +# To add secrets for a new app, just add lines here. +#------------------------------------------------------------------------------- +APP_SECRET_DEFS=( + # Docmost + "docmost:APP_SECRET:hex:32" + "docmost:DB_PASSWORD:password:24" + + # Immich + "immich:DB_PASSWORD:password:24" + + # Paperless-ngx + "paperless-ngx:PAPERLESS_SECRET_KEY:hex:32" + "paperless-ngx:DB_PASSWORD:password:24" + "paperless-ngx:PAPERLESS_ADMIN_USER:static:admin" + "paperless-ngx:PAPERLESS_ADMIN_PASSWORD:password:16" + + # ROMM + "romm:DB_PASSWORD:password:24" + "romm:MYSQL_ROOT_PASSWORD:password:24" + "romm:ROMM_AUTH_SECRET_KEY:hex:32" + + # Vaultwarden + "vaultwarden:ADMIN_TOKEN:hex:32" + "vaultwarden:SIGNUPS_ALLOWED:static:true" +) + +#------------------------------------------------------------------------------- +# Colors and logging +#------------------------------------------------------------------------------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +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_step() { echo -e "${BLUE}[STEP]${NC} $1"; } +log_success() { echo -e "${GREEN}[OK]${NC} $1"; } +log_debug() { [[ "$DEBUG_MODE" == true ]] && echo -e "${CYAN}[DEBUG]${NC} $1" || true; } + +#------------------------------------------------------------------------------- +# Help +#------------------------------------------------------------------------------- +print_help() { + cat << 'EOF' +generate-customer.sh — Generate customer-specific Portainer templates + +Creates a customer directory with baked-in secrets and domain config, +ready for zero-touch deployment via Portainer App Templates. + +USAGE: + ./scripts/generate-customer.sh [OPTIONS] + +REQUIRED: + --customer ID Customer identifier (e.g., demo-felhom) + --domain DOMAIN Customer domain (e.g., demo-felhom.eu) + +OPTIONAL: + --hdd-path PATH External HDD mount path (e.g., /mnt/hdd_1) + If omitted, apps needing HDD show a field in Portainer. + --apps LIST Comma-separated app list (default: all available) + Available: actualbudget,docmost,filebrowser,homebox, + immich,mealie,paperless-ngx,romm, + stirling-pdf,vaultwarden + --push Git commit and push to customers-felhom.eu repo + --dry-run Show what would be done, create nothing + --debug Verbose output + -h, --help Show this help + +OUTPUT: + ./output// + ├── templates.json Portainer App Templates (point --templates here) + ├── secrets.env All generated secrets (reference) + ├── actualbudget/docker-compose.yml + ├── docmost/docker-compose.yml + └── ... + + Portainer templates URL: + https://gitea.dooplex.hu/admin/customers-felhom.eu/raw/branch/main//templates.json + +EXAMPLES: + # All apps, with HDD + ./scripts/generate-customer.sh --customer demo-felhom \ + --domain demo-felhom.eu --hdd-path /mnt/hdd_1 --push + + # Raspberry Pi — lightweight apps only + ./scripts/generate-customer.sh --customer pi-customer-1 \ + --domain pi-customer-1.local \ + --apps actualbudget,filebrowser,mealie,stirling-pdf,vaultwarden + + # Preview without creating files + ./scripts/generate-customer.sh --customer test --domain test.local --dry-run +EOF +} + +#------------------------------------------------------------------------------- +# Argument parsing +#------------------------------------------------------------------------------- +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + --customer) + [[ $# -lt 2 || "$2" == --* ]] && { log_error "--customer requires a value"; exit 1; } + CUSTOMER_ID="$2"; shift 2 ;; + --domain) + [[ $# -lt 2 || "$2" == --* ]] && { log_error "--domain requires a value"; exit 1; } + DOMAIN="$2"; shift 2 ;; + --hdd-path) + [[ $# -lt 2 || "$2" == --* ]] && { log_error "--hdd-path requires a value"; exit 1; } + HDD_PATH="${2%/}"; shift 2 ;; # strip trailing slash + --apps) + [[ $# -lt 2 || "$2" == --* ]] && { log_error "--apps requires a value"; exit 1; } + APP_LIST="$2"; shift 2 ;; + --push) PUSH=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + --debug) DEBUG_MODE=true; shift ;; + -h|--help) print_help; exit 0 ;; + *) log_error "Unknown option: $1"; print_help; exit 1 ;; + esac + done + + # Validate required args + if [[ -z "$CUSTOMER_ID" ]]; then + log_error "Missing required: --customer" + exit 1 + fi + if [[ -z "$DOMAIN" ]]; then + log_error "Missing required: --domain" + exit 1 + fi + + # Sanitize customer ID (alphanumeric, hyphens, underscores only) + if [[ ! "$CUSTOMER_ID" =~ ^[a-zA-Z0-9_-]+$ ]]; then + log_error "Customer ID must be alphanumeric (hyphens/underscores allowed): $CUSTOMER_ID" + exit 1 + fi +} + +#------------------------------------------------------------------------------- +# Pre-flight checks +#------------------------------------------------------------------------------- +check_prerequisites() { + local missing=() + + # Must be run from repo root (templates/ and templates.json must exist) + if [[ ! -f "templates.json" ]]; then + log_error "templates.json not found. Run this script from the app-catalog-felhom.eu repo root." + exit 1 + fi + if [[ ! -d "templates" ]]; then + log_error "templates/ directory not found. Run this script from the app-catalog-felhom.eu repo root." + exit 1 + fi + + command -v jq >/dev/null 2>&1 || missing+=("jq") + command -v envsubst >/dev/null 2>&1 || missing+=("gettext-base (envsubst)") + command -v openssl >/dev/null 2>&1 || missing+=("openssl") + + if [[ "$PUSH" == true ]]; then + command -v git >/dev/null 2>&1 || missing+=("git") + fi + + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Missing required tools: ${missing[*]}" + log_error "Install with: sudo apt install ${missing[*]}" + exit 1 + fi +} + +#------------------------------------------------------------------------------- +# Determine app list +#------------------------------------------------------------------------------- +resolve_apps() { + local available_apps=() + + # Discover available apps from templates/ directory + for dir in templates/*/; do + [[ -f "${dir}docker-compose.yml" ]] && available_apps+=("$(basename "$dir")") + done + + if [[ -z "$APP_LIST" ]]; then + # Default: all available apps + APPS=("${available_apps[@]}") + else + # Parse comma-separated list + IFS=',' read -ra APPS <<< "$APP_LIST" + # Validate each app exists + for app in "${APPS[@]}"; do + local found=false + for avail in "${available_apps[@]}"; do + [[ "$app" == "$avail" ]] && { found=true; break; } + done + if [[ "$found" == false ]]; then + log_error "Unknown app: $app" + log_error "Available: ${available_apps[*]}" + exit 1 + fi + done + fi + + # Warn about HDD_PATH requirements + if [[ -z "$HDD_PATH" ]]; then + local hdd_apps=() + for app in "${APPS[@]}"; do + if [[ " $APPS_NEEDING_HDD " == *" $app "* ]]; then + hdd_apps+=("$app") + fi + done + if [[ ${#hdd_apps[@]} -gt 0 ]]; then + log_warn "No --hdd-path provided. These apps will require manual HDD_PATH input in Portainer:" + log_warn " ${hdd_apps[*]}" + fi + fi +} + +#------------------------------------------------------------------------------- +# Secret generation +#------------------------------------------------------------------------------- +generate_secret() { + local type="$1" + local param="$2" + + case "$type" in + password) + openssl rand -base64 $(( param * 2 )) 2>/dev/null \ + | tr -dc 'a-zA-Z0-9' \ + | head -c "$param" + ;; + hex) + openssl rand -hex "$param" 2>/dev/null + ;; + static) + echo "$param" + ;; + *) + log_error "Unknown secret type: $type" + return 1 + ;; + esac +} + +#------------------------------------------------------------------------------- +# Load or generate secrets for a customer +# +# Reads existing secrets.env to preserve previously generated values. +# Only generates new secrets for variables not already present. +#------------------------------------------------------------------------------- +load_or_generate_secrets() { + local customer_dir="$1" + local secrets_file="${customer_dir}/secrets.env" + + # Load existing secrets (if re-running) + declare -gA SECRETS=() + if [[ -f "$secrets_file" ]]; then + log_info "Loading existing secrets from ${secrets_file}" + while IFS='=' read -r key value; do + [[ "$key" =~ ^[[:space:]]*# ]] && continue + [[ -z "$key" ]] && continue + key="${key#"${key%%[![:space:]]*}"}" + key="${key%"${key##*[![:space:]]}"}" + [[ -n "$key" && -n "$value" ]] && SECRETS[$key]="$value" + done < "$secrets_file" + fi + + local generated=0 + local preserved=0 + + # Generate missing secrets for requested apps + for def in "${APP_SECRET_DEFS[@]}"; do + IFS=':' read -r def_app def_var def_type def_param <<< "$def" + + # Skip apps not in the customer's list + local in_list=false + for app in "${APPS[@]}"; do + [[ "$app" == "$def_app" ]] && { in_list=true; break; } + done + [[ "$in_list" == false ]] && continue + + local key="${def_app}__${def_var}" + if [[ -n "${SECRETS[$key]+x}" ]]; then + log_debug " ${def_app}/${def_var}: preserved" + (( preserved++ )) || true + else + SECRETS[$key]=$(generate_secret "$def_type" "$def_param") + log_debug " ${def_app}/${def_var}: generated (${def_type}:${def_param})" + (( generated++ )) || true + fi + done + + log_success "Secrets: ${generated} generated, ${preserved} preserved" + + # Write secrets.env + if [[ "$DRY_RUN" == false ]]; then + cat > "$secrets_file" << EOF +# Customer: ${CUSTOMER_ID} +# Domain: ${DOMAIN} +# Generated by generate-customer.sh v${SCRIPT_VERSION} +# Generated: $(date -u '+%Y-%m-%d %H:%M:%S UTC') +# +# This file is a reference copy of all generated secrets. +# Actual values are baked into the compose files and templates.json. + +# Global +DOMAIN=${DOMAIN} +$(if [[ -n "$HDD_PATH" ]]; then echo "HDD_PATH=${HDD_PATH}"; else echo "# HDD_PATH= (not set)"; fi) +EOF + # Write per-app secrets in order (namespaced to avoid collisions) + local current_app="" + for def in "${APP_SECRET_DEFS[@]}"; do + IFS=':' read -r def_app def_var _ _ <<< "$def" + local in_list=false + for app in "${APPS[@]}"; do + [[ "$app" == "$def_app" ]] && { in_list=true; break; } + done + [[ "$in_list" == false ]] && continue + + if [[ "$def_app" != "$current_app" ]]; then + echo "" >> "$secrets_file" + echo "# ${def_app}" >> "$secrets_file" + current_app="$def_app" + fi + # Namespaced key: app__VAR (matches internal SECRETS[] keys) + echo "${def_app}__${def_var}=${SECRETS[${def_app}__${def_var}]}" >> "$secrets_file" + done + + chmod 600 "$secrets_file" + fi +} + +#------------------------------------------------------------------------------- +# Generate compose files with baked-in values +#------------------------------------------------------------------------------- +generate_compose_files() { + local customer_dir="$1" + + for app in "${APPS[@]}"; do + local src="templates/${app}/docker-compose.yml" + local dst_dir="${customer_dir}/${app}" + local dst="${dst_dir}/docker-compose.yml" + + if [[ ! -f "$src" ]]; then + log_error "Template not found: $src" + exit 1 + fi + + if [[ "$DRY_RUN" == true ]]; then + log_info "[DRY RUN] Would generate: ${dst}" + continue + fi + + mkdir -p "$dst_dir" + + # Step 1: Normalize {{VAR}} → ${VAR} (handle old mustache syntax) + local tmpfile + tmpfile=$(mktemp) + sed 's/{{DOMAIN}}/${DOMAIN}/g; s/{{HDD_PATH}}/${HDD_PATH}/g' "$src" > "$tmpfile" + + # Step 2: Build envsubst variable list (only substitute known values) + local subst_vars='${DOMAIN}' + export DOMAIN + + if [[ -n "$HDD_PATH" ]]; then + subst_vars+=' ${HDD_PATH}' + export HDD_PATH + fi + + # Export app-specific secrets + for def in "${APP_SECRET_DEFS[@]}"; do + IFS=':' read -r def_app def_var _ _ <<< "$def" + [[ "$def_app" != "$app" ]] && continue + + local key="${def_app}__${def_var}" + if [[ -n "${SECRETS[$key]+x}" ]]; then + export "$def_var"="${SECRETS[$key]}" + subst_vars+=" \${${def_var}}" + fi + done + + log_debug " ${app}: subst_vars=${subst_vars}" + + # Step 3: envsubst with explicit var list (leaves unresolved vars intact) + envsubst "$subst_vars" < "$tmpfile" > "$dst" + rm -f "$tmpfile" + + # Step 4: Unset exported app-specific secrets (avoid leaking to next app) + for def in "${APP_SECRET_DEFS[@]}"; do + IFS=':' read -r def_app def_var _ _ <<< "$def" + [[ "$def_app" != "$app" ]] && continue + unset "$def_var" 2>/dev/null || true + done + + log_debug " ${app}: generated" + done +} + +#------------------------------------------------------------------------------- +# Generate customer-specific templates.json +# +# Takes the master templates.json, filters to requested apps, updates +# repository URLs to point at the customers repo, and removes env vars +# that have been baked into compose files. +#------------------------------------------------------------------------------- +generate_templates_json() { + local customer_dir="$1" + + if [[ "$DRY_RUN" == true ]]; then + log_info "[DRY RUN] Would generate: ${customer_dir}/templates.json" + return + fi + + # Build jq filter for app titles + # We need to match by stackfile path since titles might have different casing + local app_filters=() + for app in "${APPS[@]}"; do + app_filters+=("\"templates/${app}/docker-compose.yml\"") + done + local stackfile_array + stackfile_array=$(printf '%s,' "${app_filters[@]}") + stackfile_array="[${stackfile_array%,}]" + + local keep_hdd_json="false" + [[ -z "$HDD_PATH" ]] && keep_hdd_json="true" + + jq --arg customer "$CUSTOMER_ID" \ + --arg repo "$CUSTOMERS_REPO_RAW" \ + --argjson stackfiles "$stackfile_array" \ + --argjson keep_hdd "$keep_hdd_json" \ + '{ + version: .version, + templates: [ + .templates[] + | select(.repository.stackfile as $sf | $stackfiles | index($sf)) + | .repository.url = $repo + | .repository.stackfile = ($customer + "/" + (.repository.stackfile | split("/") | .[1:] | join("/"))) + | .env = ( + if $keep_hdd then + [.env[]? | select(.name == "HDD_PATH")] + else + [] + end + ) + ] + }' templates.json > "${customer_dir}/templates.json" + + log_debug " templates.json: $(jq '.templates | length' "${customer_dir}/templates.json") app entries" +} + +#------------------------------------------------------------------------------- +# Git push to customers repo +#------------------------------------------------------------------------------- +git_push() { + local customer_dir="$1" + + if [[ "$DRY_RUN" == true ]]; then + log_info "[DRY RUN] Would push to ${CUSTOMERS_REPO_GIT}" + return + fi + + local repo_dir="${OUTPUT_BASE}/.git-repo" + + # Clone or update the customers repo + if [[ -d "${repo_dir}/.git" ]]; then + log_info "Updating existing customers repo clone..." + cd "$repo_dir" + git pull --rebase --quiet 2>/dev/null || { + log_warn "Pull failed, continuing with local state" + } + cd - > /dev/null + else + log_info "Cloning customers repo..." + mkdir -p "$repo_dir" + git clone --quiet "$CUSTOMERS_REPO_GIT" "$repo_dir" 2>/dev/null || { + # Repo might not exist yet — init a fresh one + log_info "Repo doesn't exist yet, initializing..." + cd "$repo_dir" + git init --quiet + git remote add origin "$CUSTOMERS_REPO_GIT" + # Create initial commit so we have a branch + echo "# Felhom Customer Deployments" > README.md + echo "" >> README.md + echo "Auto-generated Portainer templates and compose files per customer." >> README.md + echo "**Do not edit manually** — regenerate with generate-customer.sh from app-catalog-felhom.eu." >> README.md + git add . + git commit --quiet -m "Initial commit" + cd - > /dev/null + } + fi + + # Copy customer directory to repo + log_info "Copying ${CUSTOMER_ID}/ to repo..." + rm -rf "${repo_dir}/${CUSTOMER_ID}" + cp -r "$customer_dir" "${repo_dir}/${CUSTOMER_ID}" + + # Commit and push + cd "$repo_dir" + git add "${CUSTOMER_ID}/" + if git diff --cached --quiet 2>/dev/null; then + log_info "No changes to push (already up to date)" + else + git commit --quiet -m "Update ${CUSTOMER_ID}: $(date -u '+%Y-%m-%d %H:%M UTC')" + git push --quiet origin HEAD 2>/dev/null && \ + log_success "Pushed to ${CUSTOMERS_REPO_GIT}" || \ + log_error "Push failed — check git credentials and repo access" + fi + cd - > /dev/null +} + +#------------------------------------------------------------------------------- +# Print summary +#------------------------------------------------------------------------------- +print_summary() { + local customer_dir="$1" + local templates_url="${CUSTOMERS_REPO_RAW}/raw/branch/main/${CUSTOMER_ID}/templates.json" + + echo "" + echo -e "${BOLD}${GREEN}═══════════════════════════════════════════════════════════════${NC}" + echo -e "${BOLD}${GREEN} Customer generated: ${CUSTOMER_ID}${NC}" + echo -e "${BOLD}${GREEN}═══════════════════════════════════════════════════════════════${NC}" + echo "" + echo -e "${BOLD}Customer:${NC} ${CUSTOMER_ID}" + echo -e "${BOLD}Domain:${NC} ${DOMAIN}" + if [[ -n "$HDD_PATH" ]]; then + echo -e "${BOLD}HDD Path:${NC} ${HDD_PATH}" + else + echo -e "${BOLD}HDD Path:${NC} ${YELLOW}not set (apps will prompt in Portainer)${NC}" + fi + echo -e "${BOLD}Apps:${NC} ${APPS[*]}" + echo "" + echo -e "${BOLD}Output:${NC}" + echo " ${customer_dir}/" + for app in "${APPS[@]}"; do + echo " ├── ${app}/docker-compose.yml" + done + echo " ├── templates.json" + echo " └── secrets.env" + echo "" + echo -e "${BOLD}Portainer templates URL:${NC}" + echo " ${templates_url}" + echo "" + echo -e "${BOLD}docker-setup.sh usage:${NC}" + echo " sudo ./docker-setup.sh --domain ${DOMAIN} --customer ${CUSTOMER_ID} \\" + echo " --email certs@felhom.eu --cf-token " + echo "" + if [[ -z "$HDD_PATH" ]]; then + echo -e "${YELLOW}Note: HDD_PATH not set. Apps requiring it (filebrowser, immich," + echo -e "paperless-ngx, romm) will show an HDD Path field in Portainer.${NC}" + echo "" + fi + echo -e "${BOLD}Secrets reference:${NC}" + echo " ${customer_dir}/secrets.env" + echo "" +} + +#------------------------------------------------------------------------------- +# Main +#------------------------------------------------------------------------------- +main() { + parse_args "$@" + check_prerequisites + resolve_apps + + local customer_dir="${OUTPUT_BASE}/${CUSTOMER_ID}" + + # Print plan + echo "" + echo -e "${BOLD}Generation plan:${NC}" + echo " Customer: ${CUSTOMER_ID}" + echo " Domain: ${DOMAIN}" + echo " HDD Path: ${HDD_PATH:-}" + echo " Apps: ${APPS[*]}" + echo " Output: ${customer_dir}/" + echo " Push: ${PUSH}" + echo "" + + if [[ "$DRY_RUN" == true ]]; then + echo -e "${YELLOW}DRY RUN — no files will be created${NC}" + echo "" + fi + + # Create output directory + if [[ "$DRY_RUN" == false ]]; then + mkdir -p "$customer_dir" + fi + + log_step "1/4 — Generating secrets..." + load_or_generate_secrets "$customer_dir" + + log_step "2/4 — Generating compose files..." + generate_compose_files "$customer_dir" + + log_step "3/4 — Generating templates.json..." + generate_templates_json "$customer_dir" + + if [[ "$PUSH" == true ]]; then + log_step "4/4 — Pushing to customers repo..." + git_push "$customer_dir" + else + log_step "4/4 — Skipping push (use --push to upload)" + fi + + print_summary "$customer_dir" +} + +main "$@" \ No newline at end of file