#!/bin/bash #=============================================================================== # Felhom App Catalog - Customer Repo Renderer v2.0 # # Reads customer YAML configs and generates Docker Compose stacks # by substituting {{DOMAIN}} and {{HDD_PATH}} placeholders in templates. # # Output structure (monorepo — all customers in one Gitea repo): # output/ # ├── README.md # ├── demo-felhom/ # │ ├── actualbudget/docker-compose.yml # │ ├── immich/docker-compose.yml # │ └── ... # └── pi-customer-1/ # ├── actualbudget/docker-compose.yml # └── ... # # Portainer GitOps compose path example: # demo-felhom/actualbudget/docker-compose.yml # # Usage: # ./render.sh # Render all customers # ./render.sh --customer demo-felhom # Render specific customer # ./render.sh --dry-run # Show what would be done # ./render.sh --push # Render + commit + push to Gitea # ./render.sh --output-dir /tmp/out # Custom output directory # ./render.sh --debug # Verbose output # # Prerequisites: # - git (for push mode) # - Access to Gitea repo (for push mode) # #=============================================================================== set -euo pipefail #------------------------------------------------------------------------------- # Configuration #------------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CATALOG_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" TEMPLATES_DIR="${CATALOG_DIR}/templates" CUSTOMERS_DIR="${CATALOG_DIR}/customers" DEFAULT_OUTPUT_DIR="${CATALOG_DIR}/output" # Gitea monorepo settings (for --push mode) GITEA_REPO_URL="${GITEA_REPO_URL:-https://gitea.dooplex.hu/admin/customers-felhom.eu.git}" #------------------------------------------------------------------------------- # Colors & Logging #------------------------------------------------------------------------------- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' log_info() { echo -e "${CYAN}[INFO]${NC} $*"; } log_success() { echo -e "${GREEN}[OK]${NC} $*"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } log_debug() { [[ "${DEBUG:-false}" == "true" ]] && echo -e "[DEBUG] $*" || true; } log_step() { echo -e "\n${BOLD}[STEP]${NC} $*"; } #------------------------------------------------------------------------------- # Simple YAML parser (no external dependencies) #------------------------------------------------------------------------------- yaml_get_value() { local file="$1" key="$2" grep -E "^${key}:" "$file" 2>/dev/null | sed "s/^${key}:[[:space:]]*//" | sed 's/^"//' | sed 's/"$//' | sed "s/^'//" | sed "s/'$//" || echo "" } yaml_get_list() { local file="$1" key="$2" local in_section=false while IFS= read -r line; do if [[ "$line" =~ ^${key}: ]]; then in_section=true continue fi if $in_section; then if [[ "$line" =~ ^[[:space:]]*-[[:space:]]+(.*) ]]; then echo "${BASH_REMATCH[1]}" | sed 's/^"//' | sed 's/"$//' elif [[ "$line" =~ ^[a-zA-Z_] ]]; then break fi fi done < "$file" } yaml_get_override() { local file="$1" key="$2" local in_overrides=false while IFS= read -r line; do if [[ "$line" =~ ^overrides: ]]; then in_overrides=true continue fi if $in_overrides; then if [[ "$line" =~ ^[[:space:]]+${key}:[[:space:]]*(.*) ]]; then local val="${BASH_REMATCH[1]}" echo "$val" | sed 's/^"//' | sed 's/"$//' | sed "s/^'//" | sed "s/'$//" return fi if [[ "$line" =~ ^[a-zA-Z_] ]]; then break fi fi done < "$file" echo "" } #------------------------------------------------------------------------------- # Template rendering (one customer) #------------------------------------------------------------------------------- render_customer() { local customer_file="$1" local output_base="$2" local customer_id domain hdd_path customer_id=$(yaml_get_value "$customer_file" "customer_id") domain=$(yaml_get_value "$customer_file" "domain") hdd_path=$(yaml_get_value "$customer_file" "hdd_path") if [[ -z "$customer_id" || -z "$domain" ]]; then log_error "Missing customer_id or domain in: $customer_file" return 1 fi log_step "Rendering customer: ${BOLD}${customer_id}${NC} (${domain})" # Check auto_update override local auto_update auto_update=$(yaml_get_override "$customer_file" "auto_update") if [[ "$auto_update" == "false" ]]; then log_warn "auto_update=false — skipping this customer" return 0 fi # Get app list local apps=() while IFS= read -r app; do [[ -n "$app" ]] && apps+=("$app") done < <(yaml_get_list "$customer_file" "apps") if [[ ${#apps[@]} -eq 0 ]]; then log_warn "No apps configured for $customer_id" return 0 fi log_info "Apps: ${apps[*]}" log_info "Domain: $domain" if [[ -n "$hdd_path" ]]; then log_info "HDD path: $hdd_path" fi # Output directory: output// (no -stacks suffix) local customer_output="${output_base}/${customer_id}" # Strip .git from repo URL for display local repo_display="${GITEA_REPO_URL%.git}" if [[ "$DRY_RUN" == "true" ]]; then echo -e " ${CYAN}[DRY-RUN]${NC} Would create: ${customer_output}/" for app in "${apps[@]}"; do echo -e " ${CYAN}[DRY-RUN]${NC} ${app}/docker-compose.yml ({{DOMAIN}} → ${domain})" # Check if template uses HDD_PATH local template="${TEMPLATES_DIR}/${app}/docker-compose.yml" if [[ -f "$template" ]] && grep -q '{{HDD_PATH}}' "$template"; then if [[ -n "$hdd_path" ]]; then echo -e " ${CYAN}[DRY-RUN]${NC} ↳ HDD path: {{HDD_PATH}} → ${hdd_path}" else echo -e " ${YELLOW}[DRY-RUN]${NC} ↳ ⚠ Template uses {{HDD_PATH}} but hdd_path not set!" fi fi local version_override version_override=$(yaml_get_override "$customer_file" "${app}_version") if [[ -n "$version_override" ]]; then echo -e " ${CYAN}[DRY-RUN]${NC} ↳ version pinned to: ${version_override}" fi done echo -e " ${CYAN}[DRY-RUN]${NC} Portainer compose paths:" for app in "${apps[@]}"; do echo -e " ${CYAN}[DRY-RUN]${NC} ${customer_id}/${app}/docker-compose.yml" done return 0 fi # Create output directory mkdir -p "$customer_output" # Generate a README for this customer's section cat > "${customer_output}/README.md" << EOF # ${customer_id} **Domain:** \`${domain}\` **HDD Path:** \`${hdd_path:-N/A}\` **Generated:** $(date -u '+%Y-%m-%d %H:%M:%S UTC') ## Deployed Apps | App | Portainer Compose Path | |-----|------------------------| $(for app in "${apps[@]}"; do echo "| ${app} | \`${customer_id}/${app}/docker-compose.yml\` |"; done) ## Portainer Stack Setup For each app, create a stack in Portainer: 1. **Stacks → Add Stack → Repository** 2. Repository URL: \`${repo_display}\` 3. Compose path: \`${customer_id}//docker-compose.yml\` 4. Add required environment variables (see comments in compose files) 5. Enable GitOps auto-update (optional, polling interval: 5 min) 6. Deploy --- *Auto-generated by felhom-app-catalog render.sh. Do not edit manually.* EOF # Process each app local rendered_count=0 for app in "${apps[@]}"; do local template="${TEMPLATES_DIR}/${app}/docker-compose.yml" if [[ ! -f "$template" ]]; then log_error "Template not found: ${template}" continue fi mkdir -p "${customer_output}/${app}" # Substitute {{DOMAIN}} with customer domain sed "s/{{DOMAIN}}/${domain}/g" "$template" > "${customer_output}/${app}/docker-compose.yml" # Substitute {{HDD_PATH}} if the template uses it if grep -q '{{HDD_PATH}}' "${customer_output}/${app}/docker-compose.yml"; then if [[ -z "$hdd_path" ]]; then log_error " ${app}: template uses {{HDD_PATH}} but hdd_path not set in customer config!" log_error " Add 'hdd_path: /mnt/hdd_1' (or similar) to ${customer_file}" rm "${customer_output}/${app}/docker-compose.yml" rmdir "${customer_output}/${app}" 2>/dev/null || true continue fi local clean_hdd_path="${hdd_path%/}" sed -i "s|{{HDD_PATH}}|${clean_hdd_path}|g" "${customer_output}/${app}/docker-compose.yml" fi # Apply version override if configured local version_override version_override=$(yaml_get_override "$customer_file" "${app}_version") if [[ -n "$version_override" ]]; then log_info " ${app}: applying version override → ${version_override}" case "$app" in immich) sed -i "s|ghcr.io/immich-app/immich-server:[^ ]*|ghcr.io/immich-app/immich-server:${version_override}|g" \ "${customer_output}/${app}/docker-compose.yml" sed -i "s|ghcr.io/immich-app/immich-machine-learning:[^ ]*|ghcr.io/immich-app/immich-machine-learning:${version_override}|g" \ "${customer_output}/${app}/docker-compose.yml" ;; *) local first_image first_image=$(grep -m1 "image:" "${customer_output}/${app}/docker-compose.yml" | sed 's/.*image:[[:space:]]*//') if [[ -n "$first_image" ]]; then local image_name image_name=$(echo "$first_image" | cut -d: -f1) sed -i "s|${first_image}|${image_name}:${version_override}|" \ "${customer_output}/${app}/docker-compose.yml" fi ;; esac fi rendered_count=$((rendered_count + 1)) log_debug " Rendered: ${app}/docker-compose.yml" done log_success "Rendered ${rendered_count}/${#apps[@]} apps → ${customer_output}/" } #------------------------------------------------------------------------------- # Git push — single monorepo push (all customers together) #------------------------------------------------------------------------------- push_to_gitea() { local output_dir="$1" log_step "Pushing to Gitea: ${GITEA_REPO_URL}" cd "$output_dir" # Initialize git if needed, or update remote if [[ ! -d ".git" ]]; then git init -b main git remote add origin "$GITEA_REPO_URL" log_info "Initialized new git repo" else # Ensure remote URL is current git remote set-url origin "$GITEA_REPO_URL" 2>/dev/null || \ git remote add origin "$GITEA_REPO_URL" 2>/dev/null || true fi # Stage all changes git add -A # Check if there are changes to commit if git diff --cached --quiet 2>/dev/null; then log_info "No changes to push — output matches Gitea" return 0 fi # Show what changed local changed_files changed_files=$(git diff --cached --name-only | wc -l) log_info "Changed files: ${changed_files}" if [[ "$DEBUG" == "true" ]]; then git diff --cached --name-only | head -20 fi # Commit local commit_msg="render: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" # Add summary of what customers were rendered local customers_rendered customers_rendered=$(find "$output_dir" -maxdepth 1 -mindepth 1 -type d ! -name '.git' -printf '%f ' 2>/dev/null || true) if [[ -n "$customers_rendered" ]]; then commit_msg="${commit_msg} [${customers_rendered}]" fi git commit -m "$commit_msg" # Push if git push origin main 2>/dev/null; then log_success "Pushed to ${GITEA_REPO_URL}" else log_warn "Normal push failed — trying pull+rebase first..." if git pull --rebase origin main 2>/dev/null; then git push origin main log_success "Rebased and pushed to ${GITEA_REPO_URL}" else log_warn "Rebase failed — force pushing..." git push -u origin main --force log_success "Force-pushed to ${GITEA_REPO_URL}" fi fi } #------------------------------------------------------------------------------- # Generate root README for the monorepo #------------------------------------------------------------------------------- generate_root_readme() { local output_dir="$1" shift local customer_files=("$@") local repo_display="${GITEA_REPO_URL%.git}" cat > "${output_dir}/README.md" << 'HEADER' # Felhom Customer Stacks Auto-generated Docker Compose stacks for all Felhom customers. **Do not edit manually** — regenerate with `render.sh` from `felhom-app-catalog`. HEADER echo "## Customers" >> "${output_dir}/README.md" echo "" >> "${output_dir}/README.md" echo "| Customer | Domain | Hardware | Apps |" >> "${output_dir}/README.md" echo "|----------|--------|----------|------|" >> "${output_dir}/README.md" for cf in "${customer_files[@]}"; do local cid cdomain chw capps cid=$(yaml_get_value "$cf" "customer_id") cdomain=$(yaml_get_value "$cf" "domain") chw=$(yaml_get_value "$cf" "hardware") # Count apps capps=$(yaml_get_list "$cf" "apps" | wc -l) echo "| [${cid}](./${cid}/) | \`${cdomain}\` | ${chw} | ${capps} apps |" >> "${output_dir}/README.md" done cat >> "${output_dir}/README.md" << EOF ## Portainer GitOps Setup For each app stack on a customer node: 1. **Stacks → Add Stack → Repository** 2. Repository URL: \`${repo_display}\` 3. Compose path: \`//docker-compose.yml\` 4. Set environment variables (secrets — see comments in compose files) 5. Optional: Enable auto-update polling (5 min) ## Regenerating From the \`felhom-app-catalog\` repo: \`\`\`bash ./scripts/render.sh # Render locally ./scripts/render.sh --push # Render + push to Gitea ./scripts/render.sh --dry-run # Preview changes \`\`\` --- *Last generated: $(date -u '+%Y-%m-%d %H:%M:%S UTC')* EOF log_debug "Generated root README.md" } #------------------------------------------------------------------------------- # Argument parsing #------------------------------------------------------------------------------- DRY_RUN=false PUSH=false DEBUG=false TARGET_CUSTOMER="" OUTPUT_DIR="" print_help() { cat << 'EOF' Felhom App Catalog - Customer Repo Renderer v2.0 Renders Docker Compose stacks from templates into a single Gitea monorepo. All customers share one repo, each in their own subdirectory. USAGE: ./render.sh [OPTIONS] OPTIONS: --customer ID Render only this customer (others left untouched) --output-dir DIR Output directory (default: ./output/) --push Render + commit + push to Gitea --dry-run Show what would be done --debug Verbose output -h, --help Show this help ENVIRONMENT VARIABLES: GITEA_REPO_URL Full Gitea repo URL (default: https://gitea.dooplex.hu/admin/customers-felhom.eu.git) EXAMPLES: ./render.sh # Render all customers ./render.sh --customer demo-felhom # Render one customer ./render.sh --dry-run # Preview changes ./render.sh --push # Render + push to Gitea GITEA_REPO_URL=https://gitea.example.com/org/repo.git ./render.sh --push EOF } while [[ $# -gt 0 ]]; do case $1 in --customer) TARGET_CUSTOMER="$2"; shift 2 ;; --output-dir) OUTPUT_DIR="$2"; shift 2 ;; --push) PUSH=true; shift ;; --dry-run) DRY_RUN=true; shift ;; --debug) DEBUG=true; shift ;; -h|--help) print_help; exit 0 ;; *) log_error "Unknown option: $1"; print_help; exit 1 ;; esac done OUTPUT_DIR="${OUTPUT_DIR:-${DEFAULT_OUTPUT_DIR}}" #------------------------------------------------------------------------------- # Main #------------------------------------------------------------------------------- main() { echo "" echo -e "${BOLD}Felhom App Catalog — Renderer v2.0${NC}" echo -e "Templates: ${TEMPLATES_DIR}" echo -e "Customers: ${CUSTOMERS_DIR}" echo -e "Output: ${OUTPUT_DIR}" echo -e "Gitea repo: ${GITEA_REPO_URL}" echo "" # Validate directories if [[ ! -d "$TEMPLATES_DIR" ]]; then log_error "Templates directory not found: $TEMPLATES_DIR" exit 1 fi if [[ ! -d "$CUSTOMERS_DIR" ]]; then log_error "Customers directory not found: $CUSTOMERS_DIR" exit 1 fi # Find customer configs to process local customer_files=() if [[ -n "$TARGET_CUSTOMER" ]]; then local target_file="${CUSTOMERS_DIR}/${TARGET_CUSTOMER}.yaml" if [[ ! -f "$target_file" ]]; then log_error "Customer config not found: $target_file" exit 1 fi customer_files=("$target_file") else while IFS= read -r -d '' f; do customer_files+=("$f") done < <(find "$CUSTOMERS_DIR" -name "*.yaml" -print0 | sort -z) fi if [[ ${#customer_files[@]} -eq 0 ]]; then log_error "No customer configs found in $CUSTOMERS_DIR" exit 1 fi log_info "Found ${#customer_files[@]} customer(s) to render" # Create output directory if [[ "$DRY_RUN" != "true" ]]; then mkdir -p "$OUTPUT_DIR" fi # If --push and output dir already has a .git from previous push, pull first if [[ "$PUSH" == "true" && "$DRY_RUN" != "true" && -d "${OUTPUT_DIR}/.git" ]]; then log_info "Pulling latest from Gitea before rendering..." cd "$OUTPUT_DIR" git pull --rebase origin main 2>/dev/null || true cd - > /dev/null fi # Clean output directory before rendering (preserve .git only) # This prevents stale files from old runs accumulating if [[ "$DRY_RUN" != "true" ]]; then local stale_count=0 for item in "${OUTPUT_DIR}"/*; do [[ -e "$item" ]] || continue rm -rf "$item" stale_count=$((stale_count + 1)) done # Also remove hidden files except .git for item in "${OUTPUT_DIR}"/.[!.]*; do [[ -e "$item" ]] || continue [[ "$(basename "$item")" == ".git" ]] && continue rm -rf "$item" done if [[ $stale_count -gt 0 ]]; then log_info "Cleaned ${stale_count} item(s) from output directory" fi fi # Render each customer local success_count=0 for customer_file in "${customer_files[@]}"; do if render_customer "$customer_file" "$OUTPUT_DIR"; then success_count=$((success_count + 1)) fi done # Generate root README (only when rendering all customers) if [[ -z "$TARGET_CUSTOMER" && "$DRY_RUN" != "true" ]]; then # Get ALL customer files for the root README (even if we only rendered some) local all_customer_files=() while IFS= read -r -d '' f; do all_customer_files+=("$f") done < <(find "$CUSTOMERS_DIR" -name "*.yaml" -print0 | sort -z) generate_root_readme "$OUTPUT_DIR" "${all_customer_files[@]}" fi echo "" echo -e "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e "${BOLD}${GREEN} Rendering complete: ${success_count}/${#customer_files[@]} customers${NC}" echo -e "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" if [[ "$DRY_RUN" != "true" ]]; then echo "" echo -e "${BOLD}Output structure:${NC}" # Show tree-like output for d in "${OUTPUT_DIR}"/*/; do [[ -d "$d" && "$(basename "$d")" != ".git" ]] || continue local cid cid=$(basename "$d") local app_count app_count=$(find "$d" -name "docker-compose.yml" | wc -l) echo -e " 📁 ${BOLD}${cid}/${NC} (${app_count} apps)" find "$d" -name "docker-compose.yml" -printf " %P\n" | sort done echo "" fi # Push all at once if [[ "$PUSH" == "true" && "$DRY_RUN" != "true" ]]; then push_to_gitea "$OUTPUT_DIR" elif [[ "$PUSH" != "true" && "$DRY_RUN" != "true" ]]; then echo -e "${YELLOW}Tip:${NC} Use --push to commit and push to Gitea" fi } main