diff --git a/stacks.sh b/stacks.sh new file mode 100755 index 0000000..a39f5c5 --- /dev/null +++ b/stacks.sh @@ -0,0 +1,1133 @@ +#!/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)" +UPDATE_DIR="$STACKS_DIR/_update" +BACKUP_DIR="$STACKS_DIR/_backup" +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}"; } + +# ─── 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 ─────────────────────────────────────────────────────────── + +cmd_update() { + local sub="${1:-help}"; shift || true + + case "$sub" in + install) + [[ -d "$UPDATE_DIR" ]] || die "Update dir not found: $UPDATE_DIR" + local svc="$UPDATE_DIR/stacks-update.service" + local tmr="$UPDATE_DIR/stacks-update.timer" + [[ -f "$svc" ]] || die "Missing: $svc" + [[ -f "$tmr" ]] || die "Missing: $tmr" + log "Installing update service" + if $DRY_RUN; then + info "[dry-run] ln -sf $svc /etc/systemd/system/" + info "[dry-run] ln -sf $tmr /etc/systemd/system/" + info "[dry-run] systemctl enable --now stacks-update.timer" + return + fi + ln -sf "$svc" /etc/systemd/system/stacks-update.service + ln -sf "$tmr" /etc/systemd/system/stacks-update.timer + 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) + [[ -f "$UPDATE_DIR/update.sh" ]] || die "Missing: $UPDATE_DIR/update.sh" + log "Running update now" + if $DRY_RUN; then info "[dry-run] bash $UPDATE_DIR/update.sh"; return; fi + bash "$UPDATE_DIR/update.sh" + ;; + + 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 \ + || cat "$UPDATE_DIR/update.log" 2>/dev/null \ + || warn "No update logs found" + ;; + + 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 ─────────────────────────────────────────────────────────── + +cmd_backup() { + local sub="${1:-help}"; shift || true + + case "$sub" in + install) + [[ -d "$BACKUP_DIR" ]] || die "Backup dir not found: $BACKUP_DIR" + local svc="$BACKUP_DIR/stacks-backup.service" + local tmr="$BACKUP_DIR/stacks-backup.timer" + [[ -f "$svc" ]] || die "Missing: $svc" + [[ -f "$tmr" ]] || die "Missing: $tmr" + log "Installing backup service" + if $DRY_RUN; then + info "[dry-run] ln -sf $svc /etc/systemd/system/" + info "[dry-run] ln -sf $tmr /etc/systemd/system/" + info "[dry-run] systemctl enable --now stacks-backup.timer" + return + fi + ln -sf "$svc" /etc/systemd/system/stacks-backup.service + ln -sf "$tmr" /etc/systemd/system/stacks-backup.timer + 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) + [[ -f "$BACKUP_DIR/backup.sh" ]] || die "Missing: $BACKUP_DIR/backup.sh" + log "Running backup now" + if $DRY_RUN; then info "[dry-run] bash $BACKUP_DIR/backup.sh"; return; fi + bash "$BACKUP_DIR/backup.sh" + ;; + + 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 \ + || cat "$BACKUP_DIR/backup.log" 2>/dev/null \ + || warn "No backup logs found" + ;; + + next) + systemctl list-timers stacks-backup.timer --no-pager 2>/dev/null \ + || warn "Timer not active" + ;; + + snapshots) + [[ -f "$BACKUP_DIR/.env" ]] || die "Missing env: $BACKUP_DIR/.env" + # shellcheck disable=SC1090 + set -a; source "$BACKUP_DIR/.env"; set +a + 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() { + 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]}" + } + + # Locate the stacks dir from the invoked script path + local script="${COMP_WORDS[0]}" + 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 + + 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" -- "$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() { + cat << 'EOF' +#compdef stacks.sh stacks + +_stacks() { + local script="${words[1]}" + 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 + + 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]' \ + '1:shell:(bash zsh)' + ;; + esac + ;; + esac +} + +_stacks "$@" +EOF +} + +cmd_completion() { + local shell="" install=false + + for arg in "$@"; do + case "$arg" in + bash|zsh) shell="$arg" ;; + --install) install=true ;; + *) die "Unknown argument: $arg (bash|zsh [--install])" ;; + esac + done + + if [[ -z "$shell" ]]; then + shell="$(basename "${SHELL:-bash}")" + [[ "$shell" == "bash" || "$shell" == "zsh" ]] || shell="bash" + warn "No shell specified, defaulting to: $shell" + fi + + local content + case "$shell" in + bash) content="$(_completion_bash)" ;; + zsh) content="$(_completion_zsh)" ;; + *) 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 Run the update script now + status Show timer/service status + logs Show service journal logs + next Show next scheduled run + ${CYAN}backup${RESET} install|uninstall|run|status|logs|next|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] Generate/install shell completion + ${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 "$@"