#!/usr/bin/env bash # stacks.sh — Docker Compose stack manager set -euo pipefail # ─── Bootstrap ──────────────────────────────────────────────────────────────── SCRIPT_PATH="$(realpath "${BASH_SOURCE[0]}")" SCRIPT_NAME="$(basename "$SCRIPT_PATH")" STACKS_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" ROOT_ENV="$STACKS_DIR/.env" VERSION="1.0.0" # ─── Colors ─────────────────────────────────────────────────────────────────── if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then BOLD=$'\e[1m' DIM=$'\e[2m' RED=$'\e[31m' GREEN=$'\e[32m' YELLOW=$'\e[33m' BLUE=$'\e[34m' MAGENTA=$'\e[35m' CYAN=$'\e[36m' RESET=$'\e[0m' else BOLD='' DIM='' RED='' GREEN='' YELLOW='' BLUE='' MAGENTA='' CYAN='' RESET='' fi # ─── Output ─────────────────────────────────────────────────────────────────── log() { [[ $QUIET == true ]] && return; echo -e "${BOLD}[stacks]${RESET} $*"; } info() { [[ $QUIET == true ]] && return; echo -e "${CYAN} →${RESET} $*"; } ok() { [[ $QUIET == true ]] && return; echo -e "${GREEN} ✓${RESET} $*"; } warn() { echo -e "${YELLOW} ⚠${RESET} $*" >&2; } err() { echo -e "${RED} ✗${RESET} $*" >&2; } die() { err "$*"; exit 1; } header() { [[ $QUIET == true ]] && return; echo -e "\n${BOLD}${BLUE}━━━ $* ━━━${RESET}"; } sep() { [[ $QUIET == true ]] && return; echo -e "${DIM}────────────────────────────────────────${RESET}"; } # ─── Root env + Notifications ──────────────────────────────────────────────── load_root_env() { if [[ -f "$ROOT_ENV" ]]; then # shellcheck disable=SC1090 set -a; source "$ROOT_ENV"; set +a else warn "No root .env found at $ROOT_ENV — create it from .env.example" fi } _notify() { local color="$1" text="$2" local webhook="${WEBHOOK_URL:-}" if [[ -z "$webhook" ]]; then warn "WEBHOOK_URL not set — skipping notification" return 0 fi curl -sf -o /dev/null -X POST "$webhook" \ -H 'Content-Type: application/json' \ -d "{\"message\":\"$text\",\"color\":\"$color\"}" \ || warn "Webhook notification failed" } # ─── Global Flags ───────────────────────────────────────────────────────────── DRY_RUN=false PARALLEL=false VERBOSE=false QUIET=false # ─── Stack Discovery ────────────────────────────────────────────────────────── list_all_stacks() { local stacks=() for d in "$STACKS_DIR"/*/; do [[ ! -d "$d" ]] && continue local name name="$(basename "$d")" [[ "$name" == _* ]] && continue [[ ! -f "$d/compose.yml" ]] && continue stacks+=("$name") done [[ ${#stacks[@]} -gt 0 ]] && printf '%s\n' "${stacks[@]}" } # Resolve FILTER args to a de-duplicated list of stack names. # Supports exact names, glob patterns (*git*, g*), and comma-separated lists. # Prints one stack name per line; dies if nothing matches. match_stacks() { local -a patterns=("$@") local -a all_stacks=() mapfile -t all_stacks < <(list_all_stacks) if [[ ${#patterns[@]} -eq 0 ]]; then printf '%s\n' "${all_stacks[@]}" return fi local -A seen=() local -a matched=() for pattern in "${patterns[@]}"; do IFS=',' read -ra parts <<< "$pattern" for part in "${parts[@]}"; do part="${part// /}" [[ -z "$part" ]] && continue local found=false for stack in "${all_stacks[@]}"; do # shellcheck disable=SC2254 # intentional glob in [[ ]] if [[ "$stack" == $part ]]; then if [[ -z "${seen[$stack]+x}" ]]; then seen[$stack]=1 matched+=("$stack") fi found=true fi done $found || warn "No stack matches pattern: ${BOLD}$part${RESET}" done done [[ ${#matched[@]} -eq 0 ]] && die "No matching stacks found" printf '%s\n' "${matched[@]}" } # ─── Compose Runner ─────────────────────────────────────────────────────────── run_compose() { local stack="$1"; shift local stack_dir="$STACKS_DIR/$stack" [[ ! -d "$stack_dir" ]] && { err "Stack directory not found: $stack"; return 1; } [[ ! -f "$stack_dir/compose.yml" ]] && { err "No compose.yml in: $stack"; return 1; } local -a env_flag=() [[ -f "$stack_dir/.env" ]] && env_flag=(--env-file .env) if $VERBOSE; then info "${BOLD}$stack${RESET}: docker compose ${env_flag[*]} $*" fi if $DRY_RUN; then echo -e " ${DIM}[dry-run]${RESET} ${CYAN}$stack${RESET}: docker compose ${env_flag[*]} $*" return 0 fi (cd "$stack_dir" && docker compose "${env_flag[@]}" "$@") } # Run a compose command across multiple stacks, optionally in parallel. # Usage: run_for_stacks stacks_array_ref COMPOSE_CMD... run_for_stacks() { local -n _stacks="$1"; shift local -a compose_cmd=("$@") local -i errors=0 if $PARALLEL && [[ ${#_stacks[@]} -gt 1 ]]; then log "Running in parallel: ${BOLD}${compose_cmd[*]}${RESET} on ${#_stacks[@]} stacks" local -a pids=() local -a names=() for stack in "${_stacks[@]}"; do run_compose "$stack" "${compose_cmd[@]}" & pids+=($!) names+=("$stack") done for i in "${!pids[@]}"; do if ! wait "${pids[$i]}"; then err "Failed: ${names[$i]}" ((errors++)) || true fi done else for stack in "${_stacks[@]}"; do sep if ! run_compose "$stack" "${compose_cmd[@]}"; then ((errors++)) || true fi done sep fi return $errors } # ─── Stack Commands ─────────────────────────────────────────────────────────── cmd_ls() { local -a all_stacks=() mapfile -t all_stacks < <(list_all_stacks) if [[ ${#all_stacks[@]} -eq 0 ]]; then warn "No stacks found in $STACKS_DIR" return fi printf "\n${BOLD}%-18s %-10s %-12s %s${RESET}\n" "STACK" "STATUS" "CONTAINERS" "ENV" sep for stack in "${all_stacks[@]}"; do local stack_dir="$STACKS_DIR/$stack" local env_label has_env if [[ -f "$stack_dir/.env" ]]; then env_label="${GREEN}✓ .env${RESET}" has_env=true else env_label="${YELLOW}✗ .env${RESET}" has_env=false fi local running="0" total="0" { running=$(docker ps --filter "label=com.docker.compose.project=$stack" -q 2>/dev/null | wc -l); } 2>/dev/null || running="?" { total=$( docker ps -a --filter "label=com.docker.compose.project=$stack" -q 2>/dev/null | wc -l); } 2>/dev/null || total="?" running="${running// /}" total="${total// /}" local status status_color if [[ "$running" == "?" || "$total" == "?" ]]; then status="${DIM}unknown${RESET}" status_color="$DIM" elif [[ "$total" -eq 0 ]]; then status="${DIM}stopped${RESET}" status_color="$DIM" elif [[ "$running" -eq "$total" ]]; then status="${GREEN}running${RESET}" status_color="$GREEN" elif [[ "$running" -eq 0 ]]; then status="${RED}down${RESET}" status_color="$RED" else status="${YELLOW}partial${RESET}" status_color="$YELLOW" fi printf " ${BOLD}%-16s${RESET} %-10b ${status_color}%s/%s${RESET}%-7s %b\n" \ "$stack" "$status" "$running" "$total" "" "$env_label" done echo } cmd_ps() { local -a filters=("$@") local -a stacks=() mapfile -t stacks < <(match_stacks "${filters[@]}") for stack in "${stacks[@]}"; do header "$stack" run_compose "$stack" ps --format table || true done } cmd_up() { local -a filters=("$@") local -a stacks=() mapfile -t stacks < <(match_stacks "${filters[@]}") log "Starting ${#stacks[@]} stack(s): ${stacks[*]}" run_for_stacks stacks up -d } cmd_down() { local remove_volumes=false local -a filters=() for arg in "$@"; do [[ "$arg" == "--volumes" || "$arg" == "-v" ]] && { remove_volumes=true; continue; } filters+=("$arg") done local -a stacks=() mapfile -t stacks < <(match_stacks "${filters[@]}") log "Stopping ${#stacks[@]} stack(s): ${stacks[*]}" if $remove_volumes; then warn "Removing volumes too (--volumes)" run_for_stacks stacks down --volumes else run_for_stacks stacks down fi } cmd_restart() { local -a filters=("$@") local -a stacks=() mapfile -t stacks < <(match_stacks "${filters[@]}") log "Restarting ${#stacks[@]} stack(s): ${stacks[*]}" run_for_stacks stacks restart } cmd_pull() { local -a filters=("$@") local -a stacks=() mapfile -t stacks < <(match_stacks "${filters[@]}") log "Pulling images for ${#stacks[@]} stack(s): ${stacks[*]}" run_for_stacks stacks pull } cmd_logs() { local -a dc_flags=() local -a filters=() while [[ $# -gt 0 ]]; do case "$1" in -f|--follow|-t|--timestamps|--no-color) dc_flags+=("$1"); shift ;; -n|--tail) dc_flags+=("$1" "${2:?--tail requires a value}"); shift 2 ;; --tail=*) dc_flags+=("$1"); shift ;; --since|--until) dc_flags+=("$1" "${2:?$1 requires a value}"); shift 2 ;; -*) dc_flags+=("$1"); shift ;; *) filters+=("$1"); shift ;; esac done local -a stacks=() mapfile -t stacks < <(match_stacks "${filters[@]}") for stack in "${stacks[@]}"; do [[ ${#stacks[@]} -gt 1 ]] && header "$stack" run_compose "$stack" logs "${dc_flags[@]}" || true done } cmd_exec() { [[ $# -lt 3 ]] && die "Usage: $SCRIPT_NAME exec STACK SERVICE CMD [ARGS...]" local stack="$1" service="$2"; shift 2 run_compose "$stack" exec "$service" "$@" } cmd_run_svc() { [[ $# -lt 3 ]] && die "Usage: $SCRIPT_NAME run STACK SERVICE CMD [ARGS...]" local stack="$1" service="$2"; shift 2 run_compose "$stack" run --rm "$service" "$@" } cmd_config() { local -a filters=("$@") local -a stacks=() mapfile -t stacks < <(match_stacks "${filters[@]}") for stack in "${stacks[@]}"; do header "$stack" run_compose "$stack" config done } cmd_images() { local -a filters=("$@") local -a stacks=() mapfile -t stacks < <(match_stacks "${filters[@]}") for stack in "${stacks[@]}"; do header "$stack" run_compose "$stack" images done } cmd_top() { local -a filters=("$@") local -a stacks=() mapfile -t stacks < <(match_stacks "${filters[@]}") for stack in "${stacks[@]}"; do header "$stack" run_compose "$stack" top || true done } cmd_stats() { local -a filters=("$@") local -a stacks=() mapfile -t stacks < <(match_stacks "${filters[@]}") local -a containers=() for stack in "${stacks[@]}"; do local -a running=() mapfile -t running < <( docker ps --filter "label=com.docker.compose.project=$stack" --format '{{.Names}}' 2>/dev/null ) containers+=("${running[@]+"${running[@]}"}") done if [[ ${#containers[@]} -eq 0 ]]; then warn "No running containers for the selected stacks" return fi docker stats "${containers[@]}" } # Passthrough: run any docker compose subcommand directly on a stack cmd_compose() { [[ $# -lt 1 ]] && die "Usage: $SCRIPT_NAME compose STACK [CMD...]" local stack="$1"; shift run_compose "$stack" "$@" } # ─── Update Service ─────────────────────────────────────────────────────────── # Resolve each service's image tag to its current local SHA256 ID. # Reads from the local image store (updated by 'docker compose pull'), # NOT from running containers — so the comparison reflects the pulled image. _stack_image_ids() { local stack_dir="$1"; shift local -a env_flag=("$@") local images # 'config --images' (compose v2.19+) outputs one image name per line; # fall back to parsing the resolved config yaml for older versions. if images=$(cd "$stack_dir" && docker compose "${env_flag[@]}" config --images 2>/dev/null) \ && [[ -n "$images" ]]; then echo "$images" else cd "$stack_dir" && docker compose "${env_flag[@]}" config 2>/dev/null \ | awk '/^\s+image:/{print $2}' | sort -u fi | xargs -r docker image inspect --format '{{.Id}}' 2>/dev/null | sort } _update_run() { load_root_env local -a all_stacks=() mapfile -t all_stacks < <(list_all_stacks) local -a updated=() failed=() local -i checked=0 log "Starting image update check (${#all_stacks[@]} stacks)" for stack in "${all_stacks[@]}"; do local stack_dir="$STACKS_DIR/$stack" local -a env_flag=() [[ -f "$stack_dir/.env" ]] && env_flag=(--env-file .env) info "Checking ${BOLD}$stack${RESET}" ((checked++)) || true local before after before=$(_stack_image_ids "$stack_dir" "${env_flag[@]+"${env_flag[@]}"}") (cd "$stack_dir" && docker compose "${env_flag[@]}" pull 2>&1) || true after=$(_stack_image_ids "$stack_dir" "${env_flag[@]+"${env_flag[@]}"}") if [[ "$before" != "$after" ]]; then info " Updates found — recreating $stack" if (cd "$stack_dir" && docker compose "${env_flag[@]}" up -d 2>&1); then updated+=("$stack") ok " $stack updated" else err " Failed to recreate $stack" failed+=("$stack") fi else info " Up to date" fi done log "Pruning dangling images" docker image prune -f local date_str date_str="$(date '+%Y-%m-%d %H:%M')" if [[ ${#failed[@]} -gt 0 ]]; then _notify "#cc0000" "❌ **Update failed** — $date_str\nUpdated: ${updated[*]:-none}\nFailed: ${failed[*]}" return 1 elif [[ ${#updated[@]} -gt 0 ]]; then _notify "#36a64f" "✅ **Updates applied** — $date_str\nStacks updated (${#updated[@]}/$checked): ${updated[*]}" else _notify "#36a64f" "✅ **All up to date** — $date_str ($checked stacks checked)" fi log "Update check complete" } cmd_update() { local sub="${1:-help}"; shift || true case "$sub" in install) log "Installing update service" if $DRY_RUN; then info "[dry-run] Would write /etc/systemd/system/stacks-update.{service,timer}" info "[dry-run] systemctl enable --now stacks-update.timer" return fi cat > /etc/systemd/system/stacks-update.service << EOF [Unit] Description=Docker stacks image update After=network-online.target docker.service Wants=network-online.target [Service] Type=oneshot User=root ExecStart=${SCRIPT_PATH} update run Environment=HOME=/root EOF cat > /etc/systemd/system/stacks-update.timer << 'EOF' [Unit] Description=Daily Docker stacks image update at 2:00 AM [Timer] OnCalendar=*-*-* 02:00:00 Europe/Amsterdam Persistent=true [Install] WantedBy=timers.target EOF systemctl daemon-reload systemctl enable --now stacks-update.timer ok "Update service installed and enabled" systemctl status stacks-update.timer --no-pager || true ;; uninstall) log "Uninstalling update service" if $DRY_RUN; then info "[dry-run] Would uninstall update service"; return; fi systemctl disable --now stacks-update.timer 2>/dev/null || true rm -f /etc/systemd/system/stacks-update.{service,timer} systemctl daemon-reload ok "Update service removed" ;; run) log "Running update now" if $DRY_RUN; then info "[dry-run] Would run image update across all stacks"; return; fi _update_run ;; status) systemctl status stacks-update.timer stacks-update.service --no-pager 2>/dev/null \ || warn "Update service not installed" ;; logs) journalctl -u stacks-update.service "${@}" --no-pager 2>/dev/null \ || warn "No update logs found (is the service installed?)" ;; next) systemctl list-timers stacks-update.timer --no-pager 2>/dev/null \ || warn "Timer not active" ;; help|"") echo "Usage: $SCRIPT_NAME update " echo "Subcommands: install, uninstall, run, status, logs, next" ;; *) die "Unknown update subcommand: $sub (install|uninstall|run|status|logs|next)" ;; esac } # ─── Backup Service ─────────────────────────────────────────────────────────── _backup_run() { load_root_env [[ -n "${RESTIC_REPOSITORY:-}" ]] || die "RESTIC_REPOSITORY not set in $ROOT_ENV" [[ -n "${RESTIC_PASSWORD:-}" ]] || die "RESTIC_PASSWORD not set in $ROOT_ENV" export RESTIC_REPOSITORY RESTIC_PASSWORD local data_dir="$STACKS_DIR/.data" local dump_dir="$data_dir/backup/dumps" log "Starting backup" mkdir -p "$dump_dir" # --- Postgres dumps: detect _db containers by convention --- local -a all_stacks=() mapfile -t all_stacks < <(list_all_stacks) local -a dump_errors=() local -i dumped=0 for stack in "${all_stacks[@]}"; do local container="${stack}_db" # Check the container exists and is running if docker ps --filter "name=^/${container}$" --filter "status=running" -q 2>/dev/null | grep -q .; then info "Dumping ${BOLD}$stack${RESET} DB ($container, user: $stack, db: $stack)" if docker exec "$container" pg_dump -U "$stack" "$stack" \ > "$dump_dir/${stack}.sql" 2>/dev/null; then local size size="$(du -h "$dump_dir/${stack}.sql" 2>/dev/null | cut -f1)" ok " $stack DB dumped ($size)" ((dumped++)) || true else err " Dump failed: $container" dump_errors+=("$stack") rm -f "$dump_dir/${stack}.sql" fi fi done [[ $dumped -gt 0 || ${#dump_errors[@]} -gt 0 ]] \ && info "DB dumps: $dumped ok, ${#dump_errors[@]} failed" # --- Restic backup --- log "Running restic backup of $data_dir" if ! restic backup "$data_dir" --tag stacks 2>&1; then err "FATAL: restic backup failed" _notify "#cc0000" "❌ **Backup failed** — restic error. Check \`journalctl -u stacks-backup\`." return 1 fi # --- Prune --- log "Pruning old snapshots" restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6 2>&1 \ || warn "restic forget failed" # --- Summary --- local snapshot_info repo_stats summary snapshot_info="$(restic snapshots --latest 1 --compact 2>/dev/null | tail -3 | head -1 || true)" repo_stats="$(restic stats 2>/dev/null | grep "Total Size" || true)" summary="✅ **Backup complete** — $(date '+%Y-%m-%d %H:%M')" [[ $dumped -gt 0 ]] && summary+="\nDB dumps: $dumped stacks" [[ ${#dump_errors[@]} -gt 0 ]] && summary+="\n⚠️ Failed dumps: ${dump_errors[*]}" [[ -n "$snapshot_info" ]] && summary+="\nLatest: \`$snapshot_info\`" [[ -n "$repo_stats" ]] && summary+="\nRepo: $repo_stats" _notify "#36a64f" "$summary" log "Backup complete" } cmd_backup() { local sub="${1:-help}"; shift || true case "$sub" in install) log "Installing backup service" if $DRY_RUN; then info "[dry-run] Would write /etc/systemd/system/stacks-backup.{service,timer}" info "[dry-run] systemctl enable --now stacks-backup.timer" return fi cat > /etc/systemd/system/stacks-backup.service << EOF [Unit] Description=Backup stacks data to HiDrive via restic After=network-online.target docker.service Wants=network-online.target [Service] Type=oneshot User=root ExecStart=${SCRIPT_PATH} backup run Environment=HOME=/root EOF cat > /etc/systemd/system/stacks-backup.timer << 'EOF' [Unit] Description=Daily stacks backup at 3:00 AM [Timer] OnCalendar=*-*-* 03:00:00 Europe/Amsterdam Persistent=true [Install] WantedBy=timers.target EOF systemctl daemon-reload systemctl enable --now stacks-backup.timer ok "Backup service installed and enabled" systemctl status stacks-backup.timer --no-pager || true ;; uninstall) log "Uninstalling backup service" if $DRY_RUN; then info "[dry-run] Would uninstall backup service"; return; fi systemctl disable --now stacks-backup.timer 2>/dev/null || true rm -f /etc/systemd/system/stacks-backup.{service,timer} systemctl daemon-reload ok "Backup service removed" ;; run) log "Running backup now" if $DRY_RUN; then info "[dry-run] Would run restic backup with dynamic DB detection"; return; fi _backup_run ;; status) systemctl status stacks-backup.timer stacks-backup.service --no-pager 2>/dev/null \ || warn "Backup service not installed" ;; logs) journalctl -u stacks-backup.service "${@}" --no-pager 2>/dev/null \ || warn "No backup logs found (is the service installed?)" ;; next) systemctl list-timers stacks-backup.timer --no-pager 2>/dev/null \ || warn "Timer not active" ;; snapshots) load_root_env [[ -n "${RESTIC_REPOSITORY:-}" ]] || die "RESTIC_REPOSITORY not set in $ROOT_ENV" [[ -n "${RESTIC_PASSWORD:-}" ]] || die "RESTIC_PASSWORD not set in $ROOT_ENV" export RESTIC_REPOSITORY RESTIC_PASSWORD restic snapshots 2>/dev/null || die "restic not installed or repo not initialised" ;; help|"") echo "Usage: $SCRIPT_NAME backup " echo "Subcommands: install, uninstall, run, status, logs, next, snapshots" ;; *) die "Unknown backup subcommand: $sub (install|uninstall|run|status|logs|next|snapshots)" ;; esac } # ─── New Stack Scaffold ─────────────────────────────────────────────────────── cmd_new() { local name="" local with_db="" local with_redis=false local no_traefik=false while [[ $# -gt 0 ]]; do case "$1" in --db) with_db="${2:?--db requires a value (postgres|mysql|mariadb)}"; shift 2 ;; --db=*) with_db="${1#--db=}"; shift ;; --redis) with_redis=true; shift ;; --no-traefik) no_traefik=true; shift ;; -*) die "Unknown flag: $1" ;; *) [[ -z "$name" ]] && name="$1" || die "Unexpected argument: $1"; shift ;; esac done [[ -z "$name" ]] && die "Usage: $SCRIPT_NAME new NAME [--db postgres|mysql|mariadb] [--redis] [--no-traefik]" [[ "$name" == _* ]] && die "Stack name cannot start with underscore (_)" [[ "$name" =~ ^[a-z0-9_-]+$ ]] || die "Stack name must be lowercase alphanumeric (a-z, 0-9, -, _)" local stack_dir="$STACKS_DIR/$name" [[ -d "$stack_dir" ]] && die "Stack already exists: $name" log "Scaffolding stack: ${BOLD}$name${RESET}" [[ -n "$with_db" ]] && info " + database: $with_db" $with_redis && info " + redis" $no_traefik || info " + traefik labels" if $DRY_RUN; then info "[dry-run] Would create:" info " $stack_dir/compose.yml" info " $stack_dir/.env.example" return fi mkdir -p "$stack_dir" # --- .env.example --- { echo "# Network" echo "NETWORK_NAME=falcon_network" $no_traefik || echo "TRAEFIK_HOST=${name}.example.com" echo "" echo "# Timezone" echo "TIMEZONE=Europe/Amsterdam" echo "" echo "# Application" echo "# APP_SECRET=$(openssl rand -hex 32 2>/dev/null || echo 'changeme')" if [[ -n "$with_db" ]]; then echo "" echo "# Database" echo "DB_NAME=$name" echo "DB_USER=$name" echo "DB_PASSWORD=changeme" fi } > "$stack_dir/.env.example" # --- compose.yml --- { echo "services:" echo " app:" echo " image: # TODO: set image" echo " container_name: ${name}" echo " environment:" echo " TZ: \${TIMEZONE:-Europe/Amsterdam}" if [[ -n "$with_db" ]]; then echo " # DB_HOST: ${name}_db" echo " # DB_NAME: \${DB_NAME}" echo " # DB_USER: \${DB_USER}" echo " # DB_PASSWORD: \${DB_PASSWORD}" fi if $with_redis; then echo " # REDIS_URL: redis://${name}_redis:6379" fi echo " volumes:" echo " - ../.data/${name}/data:/data" echo " restart: always" if [[ -n "$with_db" ]]; then echo " depends_on:" echo " db:" echo " condition: service_healthy" fi echo " healthcheck:" echo " test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:PORT/health\"]" echo " interval: 30s" echo " timeout: 5s" echo " retries: 3" echo " start_period: 15s" if $no_traefik; then echo " ports:" echo " - \"8080:8080\" # TODO: adjust port" else cat << LABELS labels: - "traefik.enable=true" - "traefik.http.routers.${name}-web.rule=Host(\`\${TRAEFIK_HOST}\`)" - "traefik.http.routers.${name}-web.entrypoints=web" - "traefik.http.routers.${name}-web.middlewares=${name}-redirect-web-secure" - "traefik.http.middlewares.${name}-redirect-web-secure.redirectscheme.scheme=https" - "traefik.http.routers.${name}-web-secure.rule=Host(\`\${TRAEFIK_HOST}\`)" - "traefik.http.routers.${name}-web-secure.entrypoints=web-secure" - "traefik.http.routers.${name}-web-secure.tls.certresolver=resolver" - "traefik.http.routers.${name}-web-secure.middlewares=security-headers@file" - "traefik.http.services.${name}-web-secure.loadbalancer.server.port=8080" - "traefik.docker.network=\${NETWORK_NAME}" LABELS fi echo " networks:" echo " - compose_network" # --- DB service --- if [[ -n "$with_db" ]]; then echo "" case "$with_db" in postgres|postgresql) cat << POSTGRES db: image: postgres:16-alpine container_name: ${name}_db environment: POSTGRES_DB: \${DB_NAME:-$name} POSTGRES_USER: \${DB_USER:-$name} POSTGRES_PASSWORD: \${DB_PASSWORD:-changeme} volumes: - ../.data/${name}/db:/var/lib/postgresql/data restart: always healthcheck: test: ["CMD-SHELL", "pg_isready -U \$\${POSTGRES_USER} -d \$\${POSTGRES_DB}"] interval: 5s timeout: 5s retries: 5 networks: - compose_network POSTGRES ;; mysql) cat << MYSQL db: image: mysql:8 container_name: ${name}_db environment: MYSQL_DATABASE: \${DB_NAME:-$name} MYSQL_USER: \${DB_USER:-$name} MYSQL_PASSWORD: \${DB_PASSWORD:-changeme} MYSQL_ROOT_PASSWORD: \${DB_ROOT_PASSWORD:-rootchangeme} volumes: - ../.data/${name}/db:/var/lib/mysql restart: always healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 5s timeout: 5s retries: 5 networks: - compose_network MYSQL ;; mariadb) cat << MARIADB db: image: mariadb:11 container_name: ${name}_db environment: MARIADB_DATABASE: \${DB_NAME:-$name} MARIADB_USER: \${DB_USER:-$name} MARIADB_PASSWORD: \${DB_PASSWORD:-changeme} MARIADB_ROOT_PASSWORD: \${DB_ROOT_PASSWORD:-rootchangeme} volumes: - ../.data/${name}/db:/var/lib/mysql restart: always healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 5s timeout: 5s retries: 5 networks: - compose_network MARIADB ;; *) die "Unknown DB type: $with_db (postgres|mysql|mariadb)" ;; esac fi # --- Redis service --- if $with_redis; then echo "" cat << REDIS redis: image: redis:7-alpine container_name: ${name}_redis restart: always healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 networks: - compose_network REDIS fi echo "" echo "networks:" echo " compose_network:" echo " name: \${NETWORK_NAME}" echo " external: true" } > "$stack_dir/compose.yml" ok "Scaffolded: ${BOLD}$name${RESET}" info " Edit: ${stack_dir}/compose.yml" info " Env: cp ${stack_dir}/.env.example ${stack_dir}/.env && \$EDITOR ${stack_dir}/.env" info " Start: $SCRIPT_NAME up $name" } # ─── Shell Completion ───────────────────────────────────────────────────────── _completion_bash() { local stacks_init="$1" # pre-rendered stacks array initialisation block cat << 'EOF' # stacks.sh bash completion — source this file or place in /etc/bash_completion.d/ _stacks_complete() { local cur prev words cword _init_completion 2>/dev/null || { COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" } local script="${COMP_WORDS[0]}" EOF echo "$stacks_init" cat << 'EOF' local commands="ls ps up down restart pull logs exec run config images top stats compose update backup new completion version help" local update_subs="install uninstall run status logs next" local backup_subs="install uninstall run status logs next snapshots" local global_flags="--dry-run --parallel --verbose --quiet --help --version" case "${COMP_WORDS[1]}" in update) COMPREPLY=( $(compgen -W "$update_subs" -- "$cur") ); return ;; backup) COMPREPLY=( $(compgen -W "$backup_subs" -- "$cur") ); return ;; completion) COMPREPLY=( $(compgen -W "bash zsh --install --static" -- "$cur") ); return ;; new) case "$prev" in --db) COMPREPLY=( $(compgen -W "postgres mysql mariadb" -- "$cur") ); return ;; new) COMPREPLY=(); return ;; *) COMPREPLY=( $(compgen -W "--db --redis --no-traefik" -- "$cur") ); return ;; esac ;; exec|run) if [[ $COMP_CWORD -eq 2 ]]; then COMPREPLY=( $(compgen -W "${stacks[*]}" -- "$cur") ) fi return ;; compose) if [[ $COMP_CWORD -eq 2 ]]; then COMPREPLY=( $(compgen -W "${stacks[*]}" -- "$cur") ) fi return ;; down) COMPREPLY=( $(compgen -W "${stacks[*]} --volumes" -- "$cur") ); return ;; logs) case "$prev" in -n|--tail|--since|--until) COMPREPLY=(); return ;; esac COMPREPLY=( $(compgen -W "${stacks[*]} -f --follow -n --tail --since --until -t --timestamps" -- "$cur") ) return ;; up|restart|pull|ps|config|images|top|stats) COMPREPLY=( $(compgen -W "${stacks[*]}" -- "$cur") ); return ;; esac if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "$global_flags" -- "$cur") ); return fi if [[ $COMP_CWORD -eq 1 ]]; then COMPREPLY=( $(compgen -W "$commands" -- "$cur") ) fi } complete -F _stacks_complete stacks.sh complete -F _stacks_complete stacks EOF } _completion_zsh() { local stacks_init="$1" # pre-rendered stacks array initialisation block cat << 'EOF' #compdef stacks.sh stacks _stacks() { local script="${words[1]}" EOF echo "$stacks_init" cat << 'EOF' local -a commands=( 'ls:List all stacks with live status' 'ps:Show container status' 'up:Start stack(s)' 'down:Stop stack(s)' 'restart:Restart stack(s)' 'pull:Pull latest images' 'logs:Show logs' 'exec:Execute command in a running container' 'run:Run a one-off command in a new container' 'config:Show resolved compose config' 'images:Show images used by stack(s)' 'top:Display running processes' 'stats:Live container resource stats' 'compose:Pass-through to docker compose' 'update:Manage the update systemd service' 'backup:Manage the backup systemd service' 'new:Scaffold a new stack' 'completion:Generate shell completion script' 'version:Show version' 'help:Show help' ) local -a update_subs=( 'install:Install and enable update timer' 'uninstall:Remove update timer' 'run:Run update now' 'status:Show update service status' 'logs:Show update service logs' 'next:Show next scheduled run' ) local -a backup_subs=( 'install:Install and enable backup timer' 'uninstall:Remove backup timer' 'run:Run backup now' 'status:Show backup service status' 'logs:Show backup service logs' 'next:Show next scheduled run' 'snapshots:List restic snapshots' ) _arguments -C \ '(-n --dry-run)'{-n,--dry-run}'[Show what would be done without executing]' \ '(-p --parallel)'{-p,--parallel}'[Run operations in parallel]' \ '(-v --verbose)'{-v,--verbose}'[Verbose output]' \ '(-q --quiet)'{-q,--quiet}'[Suppress non-error output]' \ '(-h --help)'{-h,--help}'[Show help]' \ '--version[Show version]' \ '1:command:->cmd' \ '*:args:->args' && return case "$state" in cmd) _describe 'command' commands ;; args) case "${words[2]}" in up|restart|pull|ps|config|images|top|stats) _values 'stack' "${stacks[@]}" ;; down) _arguments \ '--volumes[Also remove volumes]' \ '*:stack:'"(${stacks[*]})" ;; logs) _arguments \ '(-f --follow)'{-f,--follow}'[Follow log output]' \ '(-n --tail)'{-n,--tail}'[Number of lines to show]:lines' \ '--since[Show logs since timestamp]:timestamp' \ '--until[Show logs until timestamp]:timestamp' \ '(-t --timestamps)'{-t,--timestamps}'[Show timestamps]' \ '*:stack:'"(${stacks[*]})" ;; exec|run|compose) if [[ $CURRENT -eq 3 ]]; then _values 'stack' "${stacks[@]}" fi ;; update) _describe 'subcommand' update_subs ;; backup) _describe 'subcommand' backup_subs ;; new) _arguments \ '--db[Add a database service]:engine:(postgres mysql mariadb)' \ '--redis[Add a Redis service]' \ '--no-traefik[Skip Traefik labels, expose port instead]' \ '1:name:_message "stack name"' ;; completion) _arguments \ '--install[Install the completion file]' \ '--static[Bake current stack list into completion script]' \ '1:shell:(bash zsh)' ;; esac ;; esac } _stacks "$@" EOF } cmd_completion() { local shell="" install=false static=false for arg in "$@"; do case "$arg" in bash|zsh) shell="$arg" ;; --install) install=true ;; --static) static=true ;; *) die "Unknown argument: $arg (bash|zsh [--install] [--static])" ;; esac done if [[ -z "$shell" ]]; then shell="$(basename "${SHELL:-bash}")" [[ "$shell" == "bash" || "$shell" == "zsh" ]] || shell="bash" warn "No shell specified, defaulting to: $shell" fi # Build the stacks array initialisation block — either dynamic (runtime discovery) # or static (current list baked in at generation time). local bash_stacks_init zsh_stacks_init if $static; then local -a cur_stacks=() mapfile -t cur_stacks < <(list_all_stacks) [[ ${#cur_stacks[@]} -eq 0 ]] && die "No stacks found to bake into completion" local generated_at generated_at="$(date '+%Y-%m-%d')" bash_stacks_init=" # Stacks baked in at generation time (${generated_at}) — rerun to update local -a stacks=(${cur_stacks[*]})" zsh_stacks_init=" # Stacks baked in at generation time (${generated_at}) — rerun to update local -a stacks=(${cur_stacks[*]})" info "Baking ${#cur_stacks[@]} stacks: ${cur_stacks[*]}" else bash_stacks_init=' # Dynamically discover stacks from the script location at completion time local stacks_dir stacks_dir="$(cd "$(dirname "$(realpath "$script" 2>/dev/null || echo "$script")")" 2>/dev/null && pwd)" local -a stacks=() if [[ -d "$stacks_dir" ]]; then for d in "$stacks_dir"/*/; do local n; n="$(basename "$d")" [[ "$n" == _* ]] && continue [[ -f "$d/compose.yml" ]] && stacks+=("$n") done fi' zsh_stacks_init=' # Dynamically discover stacks from the script location at completion time local stacks_dir stacks_dir="$(cd "$(dirname "$(realpath "$script" 2>/dev/null || echo "$script")")" 2>/dev/null && pwd)" local -a stacks=() if [[ -d "$stacks_dir" ]]; then for d in "$stacks_dir"/*/; do local n="${d:t}" [[ "$n" == _* ]] && continue [[ -f "${d}compose.yml" ]] && stacks+=("$n") done fi' fi local content case "$shell" in bash) content="$(_completion_bash "$bash_stacks_init")" ;; zsh) content="$(_completion_zsh "$zsh_stacks_init")" ;; *) die "Unsupported shell: $shell (bash|zsh)" ;; esac if ! $install; then echo "$content" return fi local dest case "$shell" in bash) if [[ -w /etc/bash_completion.d ]]; then dest="/etc/bash_completion.d/stacks" else dest="${HOME}/.local/share/bash-completion/completions/stacks" mkdir -p "$(dirname "$dest")" fi ;; zsh) dest="" # prefer first writable fpath dir local -a fpath_dirs=() while IFS=: read -ra fpath_dirs; do :; done <<< "${FPATH:-}" for d in "${fpath_dirs[@]}"; do [[ -d "$d" && -w "$d" ]] && { dest="$d/_stacks"; break; } done if [[ -z "$dest" ]]; then dest="${HOME}/.zsh/completions/_stacks" mkdir -p "$(dirname "$dest")" info "Add to ~/.zshrc: ${BOLD}fpath=(~/.zsh/completions \$fpath)${RESET}" info "Then reload: ${BOLD}autoload -U compinit && compinit${RESET}" fi ;; esac if $DRY_RUN; then info "[dry-run] Would write $shell completion to: $dest" return fi echo "$content" > "$dest" ok "$shell completion installed: $dest" [[ "$shell" == "bash" ]] && info "Reload: source $dest" } # ─── Help ───────────────────────────────────────────────────────────────────── cmd_help() { cat << EOF ${BOLD}stacks.sh${RESET} v${VERSION} — Docker Compose stack manager ${BOLD}USAGE${RESET} ${CYAN}${SCRIPT_NAME}${RESET} [OPTIONS] COMMAND [FILTER...] [-- ARGS...] ${BOLD}GLOBAL OPTIONS${RESET} -n, --dry-run Show commands without executing -p, --parallel Run multi-stack operations in parallel -v, --verbose Print each docker compose call before running -q, --quiet Suppress informational output -h, --help Show this help --version Show version (${VERSION}) ${BOLD}STACK COMMANDS${RESET} ${CYAN}ls${RESET} List stacks with live container status ${CYAN}ps${RESET} [FILTER...] Container status table ${CYAN}up${RESET} [FILTER...] Start stacks (compose up -d) ${CYAN}down${RESET} [FILTER...] [-v] Stop stacks (--volumes to also remove volumes) ${CYAN}restart${RESET} [FILTER...] Restart stacks ${CYAN}pull${RESET} [FILTER...] Pull latest images ${CYAN}logs${RESET} [FILTER...] [FLAGS] Show logs (-f to follow, -n N for tail) ${CYAN}config${RESET} [FILTER...] Show resolved compose config ${CYAN}images${RESET} [FILTER...] Show images used by stacks ${CYAN}top${RESET} [FILTER...] Display running processes ${CYAN}stats${RESET} [FILTER...] Live container resource usage ${BOLD}SINGLE-STACK COMMANDS${RESET} ${CYAN}exec${RESET} STACK SVC CMD... Execute in a running container ${CYAN}run${RESET} STACK SVC CMD... Run a one-off command (--rm) ${CYAN}compose${RESET} STACK [CMD...] Pass-through to docker compose ${BOLD}SERVICE MANAGEMENT${RESET} ${CYAN}update${RESET} install Link & enable systemd update timer uninstall Disable & remove update timer run Pull all images, recreate changed containers, prune status Show timer/service status logs Show journal logs next Show next scheduled run ${CYAN}backup${RESET} install Link & enable systemd backup timer uninstall Disable & remove backup timer run Dump DBs + restic backup + prune snapshots status Show timer/service status logs Show journal logs next Show next scheduled run snapshots List restic snapshots ${BOLD}UTILITIES${RESET} ${CYAN}new${RESET} NAME [OPTIONS] Scaffold a new stack --db postgres|mysql|mariadb Add a database service --redis Add a Redis service --no-traefik Expose port instead of Traefik labels ${CYAN}completion${RESET} bash|zsh [--install] [--static] Generate/install shell completion --static Bake current stack list in (no runtime discovery) ${CYAN}version${RESET} Print version ${CYAN}help${RESET} Show this help ${BOLD}FILTER SYNTAX${RESET} One or more stack names or glob patterns, space- or comma-separated. Multiple filters are OR-merged and de-duplicated. Omit FILTER to match all stacks. Examples: gitea traefik exact names 'g*' glob: all stacks starting with g '*,!traefik' (note: negation not supported — use exclusion manually) ${BOLD}EXAMPLES${RESET} ${SCRIPT_NAME} ls ${SCRIPT_NAME} up ${SCRIPT_NAME} up gitea traefik ${SCRIPT_NAME} down 'g*' --volumes ${SCRIPT_NAME} restart 'g*,traefik' ${SCRIPT_NAME} logs -f gitea ${SCRIPT_NAME} logs -n 100 gitea n8n ${SCRIPT_NAME} exec gitea gitea gitea admin user list ${SCRIPT_NAME} run passbolt passbolt bin/cake passbolt healthcheck ${SCRIPT_NAME} pull --parallel ${SCRIPT_NAME} pull --dry-run ${SCRIPT_NAME} update run ${SCRIPT_NAME} update install ${SCRIPT_NAME} backup run ${SCRIPT_NAME} backup snapshots ${SCRIPT_NAME} new myapp --db postgres --redis ${SCRIPT_NAME} completion zsh --install EOF } # ─── Entry Point ────────────────────────────────────────────────────────────── main() { local -a args=() # Parse global flags first (allow them anywhere before the command) for arg in "$@"; do case "$arg" in -n|--dry-run) DRY_RUN=true ;; -p|--parallel) PARALLEL=true ;; -v|--verbose) VERBOSE=true ;; -q|--quiet) QUIET=true ;; -h|--help) cmd_help; exit 0 ;; --version) echo "stacks.sh v$VERSION"; exit 0 ;; --) break ;; *) args+=("$arg") ;; esac done local command="${args[0]:-}" local -a rest=("${args[@]:1}") $DRY_RUN && warn "Dry-run mode — no changes will be made" case "$command" in ls|list) cmd_ls ;; ps|status) cmd_ps "${rest[@]+"${rest[@]}"}" ;; up|start) cmd_up "${rest[@]+"${rest[@]}"}" ;; down|stop) cmd_down "${rest[@]+"${rest[@]}"}" ;; restart) cmd_restart "${rest[@]+"${rest[@]}"}" ;; pull) cmd_pull "${rest[@]+"${rest[@]}"}" ;; logs|log) cmd_logs "${rest[@]+"${rest[@]}"}" ;; exec) cmd_exec "${rest[@]+"${rest[@]}"}" ;; run) cmd_run_svc "${rest[@]+"${rest[@]}"}" ;; config) cmd_config "${rest[@]+"${rest[@]}"}" ;; images) cmd_images "${rest[@]+"${rest[@]}"}" ;; top) cmd_top "${rest[@]+"${rest[@]}"}" ;; stats) cmd_stats "${rest[@]+"${rest[@]}"}" ;; compose) cmd_compose "${rest[@]+"${rest[@]}"}" ;; update) cmd_update "${rest[@]+"${rest[@]}"}" ;; backup) cmd_backup "${rest[@]+"${rest[@]}"}" ;; new|scaffold) cmd_new "${rest[@]+"${rest[@]}"}" ;; completion) cmd_completion "${rest[@]+"${rest[@]}"}" ;; version) echo "stacks.sh v$VERSION" ;; help|"") cmd_help ;; *) err "Unknown command: ${BOLD}$command${RESET}" echo -e " Run ${CYAN}$SCRIPT_NAME help${RESET} for usage." exit 1 ;; esac } main "$@"