448 lines
16 KiB
Bash
Executable File
448 lines
16 KiB
Bash
Executable File
#!/bin/bash
|
|
#===============================================================================
|
|
# Felhom App Catalog - Customer Repo Renderer v1.0
|
|
#
|
|
# Reads customer YAML configs and generates per-customer stack directories
|
|
# by substituting {{DOMAIN}} placeholders in Docker Compose templates.
|
|
#
|
|
# 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 + 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 (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 settings (for --push mode)
|
|
GITEA_URL="${GITEA_URL:-https://gitea.felhom.eu}"
|
|
GITEA_ORG="${GITEA_ORG:-customers}"
|
|
|
|
#-------------------------------------------------------------------------------
|
|
# 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)
|
|
# Handles our flat customer YAML format
|
|
#-------------------------------------------------------------------------------
|
|
yaml_get_value() {
|
|
# Get a simple key: value from YAML file
|
|
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() {
|
|
# Get a YAML list (items starting with " - ")
|
|
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_] ]] || [[ -z "$line" && "$in_section" == true ]]; then
|
|
# New top-level key or empty line after list = end of section
|
|
# But only break on a new top-level key, not empty lines within
|
|
if [[ "$line" =~ ^[a-zA-Z_] ]]; then
|
|
break
|
|
fi
|
|
fi
|
|
fi
|
|
done < "$file"
|
|
}
|
|
|
|
yaml_get_override() {
|
|
# Get a specific override value: overrides.key
|
|
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
|
|
#-------------------------------------------------------------------------------
|
|
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 for this customer
|
|
local customer_output="${output_base}/${customer_id}-stacks"
|
|
|
|
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
|
|
# Show version override if any
|
|
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
|
|
return 0
|
|
fi
|
|
|
|
# Create output directory
|
|
mkdir -p "$customer_output"
|
|
|
|
# Generate a README for the customer repo
|
|
cat > "${customer_output}/README.md" << EOF
|
|
# ${customer_id} - Application Stacks
|
|
|
|
**Domain:** \`${domain}\`
|
|
**HDD Path:** \`${hdd_path:-N/A (no HDD apps)}\`
|
|
**Generated:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')
|
|
**Source:** felhom-app-catalog (render.sh)
|
|
|
|
## Deployed Apps
|
|
|
|
| App | Compose Path |
|
|
|-----|-------------|
|
|
$(for app in "${apps[@]}"; do echo "| ${app} | \`${app}/docker-compose.yml\` |"; done)
|
|
|
|
## Portainer Setup
|
|
|
|
For each app, create a stack in Portainer:
|
|
1. **Stacks → Add Stack → Repository**
|
|
2. Repository URL: \`${GITEA_URL}/${GITEA_ORG}/${customer_id}-stacks\`
|
|
3. Compose path: \`<appname>/docker-compose.yml\`
|
|
4. Add required environment variables (see comments in compose files)
|
|
5. Deploy
|
|
|
|
---
|
|
*Auto-generated by felhom-app-catalog. 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
|
|
# Remove trailing slash from hdd_path if present
|
|
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}"
|
|
# Find the main image line and replace the tag
|
|
# This is a simple approach — works for single-image apps and
|
|
# the first image line in multi-service apps
|
|
case "$app" in
|
|
immich)
|
|
# Immich has server + ML images that should match
|
|
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"
|
|
;;
|
|
*)
|
|
# Generic: replace the first image tag (main service)
|
|
# Get the first image line and replace its tag
|
|
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 to Gitea
|
|
#-------------------------------------------------------------------------------
|
|
push_to_gitea() {
|
|
local customer_dir="$1"
|
|
local customer_id
|
|
customer_id=$(basename "$customer_dir" | sed 's/-stacks$//')
|
|
|
|
local repo_url="${GITEA_URL}/${GITEA_ORG}/${customer_id}-stacks.git"
|
|
|
|
log_step "Pushing to Gitea: ${repo_url}"
|
|
|
|
cd "$customer_dir"
|
|
|
|
if [[ ! -d ".git" ]]; then
|
|
git init -b main
|
|
git remote add origin "$repo_url"
|
|
fi
|
|
|
|
git add -A
|
|
if git diff --cached --quiet; then
|
|
log_info "No changes to push for ${customer_id}"
|
|
return 0
|
|
fi
|
|
|
|
git commit -m "Auto-render: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
|
|
|
if git push origin main 2>/dev/null; then
|
|
log_success "Pushed to ${repo_url}"
|
|
else
|
|
# Try force push if first time or diverged
|
|
log_warn "Normal push failed, trying force push..."
|
|
git push -u origin main --force
|
|
log_success "Force-pushed to ${repo_url}"
|
|
fi
|
|
}
|
|
|
|
#-------------------------------------------------------------------------------
|
|
# Argument parsing
|
|
#-------------------------------------------------------------------------------
|
|
DRY_RUN=false
|
|
PUSH=false
|
|
DEBUG=false
|
|
TARGET_CUSTOMER=""
|
|
OUTPUT_DIR=""
|
|
|
|
print_help() {
|
|
cat << 'EOF'
|
|
Felhom App Catalog - Customer Repo Renderer v1.0
|
|
|
|
Generates per-customer Docker Compose stacks from templates.
|
|
|
|
USAGE:
|
|
./render.sh [OPTIONS]
|
|
|
|
OPTIONS:
|
|
--customer ID Render only this customer
|
|
--output-dir DIR Output directory (default: ./output/)
|
|
--push Push rendered repos to Gitea
|
|
--dry-run Show what would be done
|
|
--debug Verbose output
|
|
-h, --help Show this help
|
|
|
|
ENVIRONMENT VARIABLES:
|
|
GITEA_URL Gitea base URL (default: https://gitea.felhom.eu)
|
|
GITEA_ORG Gitea organization (default: customers)
|
|
|
|
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
|
|
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${NC}"
|
|
echo -e "Templates: ${TEMPLATES_DIR}"
|
|
echo -e "Customers: ${CUSTOMERS_DIR}"
|
|
echo -e "Output: ${OUTPUT_DIR}"
|
|
echo ""
|
|
|
|
# Validate directories exist
|
|
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
|
|
|
|
# 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))
|
|
|
|
# Push to Gitea if requested
|
|
if [[ "$PUSH" == "true" && "$DRY_RUN" != "true" ]]; then
|
|
local cid
|
|
cid=$(yaml_get_value "$customer_file" "customer_id")
|
|
push_to_gitea "${OUTPUT_DIR}/${cid}-stacks"
|
|
fi
|
|
fi
|
|
done
|
|
|
|
echo ""
|
|
echo -e "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
echo -e "${BOLD}${GREEN} Rendering complete: ${success_count}/${#customer_files[@]} customers${NC}"
|
|
echo -e "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
echo ""
|
|
|
|
if [[ "$DRY_RUN" != "true" ]]; then
|
|
echo -e "${BOLD}Output:${NC}"
|
|
find "$OUTPUT_DIR" -name "docker-compose.yml" -printf " %P\n" | sort
|
|
echo ""
|
|
fi
|
|
|
|
if [[ "$PUSH" != "true" && "$DRY_RUN" != "true" ]]; then
|
|
echo -e "${YELLOW}Tip:${NC} Use --push to automatically push to Gitea repos"
|
|
fi
|
|
}
|
|
|
|
main
|