diff --git a/stacks.sh b/stacks.sh deleted file mode 100755 index 0d886f4..0000000 --- a/stacks.sh +++ /dev/null @@ -1,1368 +0,0 @@ -#!/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[@]}"}") || true - (cd "$stack_dir" && docker compose "${env_flag[@]}" pull 2>&1) || true - after=$(_stack_image_ids "$stack_dir" "${env_flag[@]+"${env_flag[@]}"}") || true - - 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) - if systemctl cat stacks-update.timer &>/dev/null; then - systemctl status stacks-update.timer stacks-update.service --no-pager 2>/dev/null || true - else - warn "Update service not installed" - fi - ;; - - 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) - if systemctl cat stacks-backup.timer &>/dev/null; then - systemctl status stacks-backup.timer stacks-backup.service --no-pager 2>/dev/null || true - else - warn "Backup service not installed" - fi - ;; - - 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 "$@"