Files
stacks/stacks.sh
T
valknar 3251f27edb feat: add stacks.sh stack manager CLI
Bash script for managing Docker Compose stacks with glob filtering,
parallel execution, service management, scaffolding, and shell completion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 19:13:43 +02:00

1134 lines
36 KiB
Bash
Executable File

#!/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 <subcommand>"
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 <subcommand>"
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 "$@"