Files
app-catalog-felhom.eu/scripts/generate-customer.sh
T

658 lines
23 KiB
Bash

#!/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)
# - <app>/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/<customer>/
├── 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/<customer>/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 <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:-<not set>}"
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 "$@"