#!/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 "$@"