8ae9c9e878
docker compose images -q reports the image IDs of currently running containers, which don't change after a pull — so before == after always and containers were never recreated. Fix: resolve each service's image tag to its local SHA256 ID via docker image inspect, which reads the local image store and correctly reflects the newly pulled image. Falls back from 'config --images' (compose v2.19+) to parsing 'config' yaml for older versions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1357 lines
44 KiB
Bash
Executable File
1357 lines
44 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)"
|
|
ROOT_ENV="$STACKS_DIR/.env"
|
|
VERSION="1.0.0"
|
|
|
|
# ─── Colors ───────────────────────────────────────────────────────────────────
|
|
|
|
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
|
|
BOLD=$'\e[1m' DIM=$'\e[2m'
|
|
RED=$'\e[31m' GREEN=$'\e[32m' YELLOW=$'\e[33m'
|
|
BLUE=$'\e[34m' MAGENTA=$'\e[35m' CYAN=$'\e[36m'
|
|
RESET=$'\e[0m'
|
|
else
|
|
BOLD='' DIM='' RED='' GREEN='' YELLOW='' BLUE='' MAGENTA='' CYAN='' RESET=''
|
|
fi
|
|
|
|
# ─── Output ───────────────────────────────────────────────────────────────────
|
|
|
|
log() { [[ $QUIET == true ]] && return; echo -e "${BOLD}[stacks]${RESET} $*"; }
|
|
info() { [[ $QUIET == true ]] && return; echo -e "${CYAN} →${RESET} $*"; }
|
|
ok() { [[ $QUIET == true ]] && return; echo -e "${GREEN} ✓${RESET} $*"; }
|
|
warn() { echo -e "${YELLOW} ⚠${RESET} $*" >&2; }
|
|
err() { echo -e "${RED} ✗${RESET} $*" >&2; }
|
|
die() { err "$*"; exit 1; }
|
|
header() { [[ $QUIET == true ]] && return; echo -e "\n${BOLD}${BLUE}━━━ $* ━━━${RESET}"; }
|
|
sep() { [[ $QUIET == true ]] && return; echo -e "${DIM}────────────────────────────────────────${RESET}"; }
|
|
|
|
# ─── Root env + Notifications ────────────────────────────────────────────────
|
|
|
|
load_root_env() {
|
|
if [[ -f "$ROOT_ENV" ]]; then
|
|
# shellcheck disable=SC1090
|
|
set -a; source "$ROOT_ENV"; set +a
|
|
else
|
|
warn "No root .env found at $ROOT_ENV — create it from .env.example"
|
|
fi
|
|
}
|
|
|
|
_notify() {
|
|
local color="$1" text="$2"
|
|
local webhook="${WEBHOOK_URL:-}"
|
|
if [[ -z "$webhook" ]]; then
|
|
warn "WEBHOOK_URL not set — skipping notification"
|
|
return 0
|
|
fi
|
|
curl -sf -o /dev/null -X POST "$webhook" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "{\"message\":\"$text\",\"color\":\"$color\"}" \
|
|
|| warn "Webhook notification failed"
|
|
}
|
|
|
|
# ─── Global Flags ─────────────────────────────────────────────────────────────
|
|
|
|
DRY_RUN=false
|
|
PARALLEL=false
|
|
VERBOSE=false
|
|
QUIET=false
|
|
|
|
# ─── Stack Discovery ──────────────────────────────────────────────────────────
|
|
|
|
list_all_stacks() {
|
|
local stacks=()
|
|
for d in "$STACKS_DIR"/*/; do
|
|
[[ ! -d "$d" ]] && continue
|
|
local name
|
|
name="$(basename "$d")"
|
|
[[ "$name" == _* ]] && continue
|
|
[[ ! -f "$d/compose.yml" ]] && continue
|
|
stacks+=("$name")
|
|
done
|
|
[[ ${#stacks[@]} -gt 0 ]] && printf '%s\n' "${stacks[@]}"
|
|
}
|
|
|
|
# Resolve FILTER args to a de-duplicated list of stack names.
|
|
# Supports exact names, glob patterns (*git*, g*), and comma-separated lists.
|
|
# Prints one stack name per line; dies if nothing matches.
|
|
match_stacks() {
|
|
local -a patterns=("$@")
|
|
local -a all_stacks=()
|
|
mapfile -t all_stacks < <(list_all_stacks)
|
|
|
|
if [[ ${#patterns[@]} -eq 0 ]]; then
|
|
printf '%s\n' "${all_stacks[@]}"
|
|
return
|
|
fi
|
|
|
|
local -A seen=()
|
|
local -a matched=()
|
|
|
|
for pattern in "${patterns[@]}"; do
|
|
IFS=',' read -ra parts <<< "$pattern"
|
|
for part in "${parts[@]}"; do
|
|
part="${part// /}"
|
|
[[ -z "$part" ]] && continue
|
|
local found=false
|
|
for stack in "${all_stacks[@]}"; do
|
|
# shellcheck disable=SC2254 # intentional glob in [[ ]]
|
|
if [[ "$stack" == $part ]]; then
|
|
if [[ -z "${seen[$stack]+x}" ]]; then
|
|
seen[$stack]=1
|
|
matched+=("$stack")
|
|
fi
|
|
found=true
|
|
fi
|
|
done
|
|
$found || warn "No stack matches pattern: ${BOLD}$part${RESET}"
|
|
done
|
|
done
|
|
|
|
[[ ${#matched[@]} -eq 0 ]] && die "No matching stacks found"
|
|
printf '%s\n' "${matched[@]}"
|
|
}
|
|
|
|
# ─── Compose Runner ───────────────────────────────────────────────────────────
|
|
|
|
run_compose() {
|
|
local stack="$1"; shift
|
|
local stack_dir="$STACKS_DIR/$stack"
|
|
|
|
[[ ! -d "$stack_dir" ]] && { err "Stack directory not found: $stack"; return 1; }
|
|
[[ ! -f "$stack_dir/compose.yml" ]] && { err "No compose.yml in: $stack"; return 1; }
|
|
|
|
local -a env_flag=()
|
|
[[ -f "$stack_dir/.env" ]] && env_flag=(--env-file .env)
|
|
|
|
if $VERBOSE; then
|
|
info "${BOLD}$stack${RESET}: docker compose ${env_flag[*]} $*"
|
|
fi
|
|
|
|
if $DRY_RUN; then
|
|
echo -e " ${DIM}[dry-run]${RESET} ${CYAN}$stack${RESET}: docker compose ${env_flag[*]} $*"
|
|
return 0
|
|
fi
|
|
|
|
(cd "$stack_dir" && docker compose "${env_flag[@]}" "$@")
|
|
}
|
|
|
|
# Run a compose command across multiple stacks, optionally in parallel.
|
|
# Usage: run_for_stacks stacks_array_ref COMPOSE_CMD...
|
|
run_for_stacks() {
|
|
local -n _stacks="$1"; shift
|
|
local -a compose_cmd=("$@")
|
|
local -i errors=0
|
|
|
|
if $PARALLEL && [[ ${#_stacks[@]} -gt 1 ]]; then
|
|
log "Running in parallel: ${BOLD}${compose_cmd[*]}${RESET} on ${#_stacks[@]} stacks"
|
|
local -a pids=()
|
|
local -a names=()
|
|
for stack in "${_stacks[@]}"; do
|
|
run_compose "$stack" "${compose_cmd[@]}" &
|
|
pids+=($!)
|
|
names+=("$stack")
|
|
done
|
|
for i in "${!pids[@]}"; do
|
|
if ! wait "${pids[$i]}"; then
|
|
err "Failed: ${names[$i]}"
|
|
((errors++)) || true
|
|
fi
|
|
done
|
|
else
|
|
for stack in "${_stacks[@]}"; do
|
|
sep
|
|
if ! run_compose "$stack" "${compose_cmd[@]}"; then
|
|
((errors++)) || true
|
|
fi
|
|
done
|
|
sep
|
|
fi
|
|
|
|
return $errors
|
|
}
|
|
|
|
# ─── Stack Commands ───────────────────────────────────────────────────────────
|
|
|
|
cmd_ls() {
|
|
local -a all_stacks=()
|
|
mapfile -t all_stacks < <(list_all_stacks)
|
|
|
|
if [[ ${#all_stacks[@]} -eq 0 ]]; then
|
|
warn "No stacks found in $STACKS_DIR"
|
|
return
|
|
fi
|
|
|
|
printf "\n${BOLD}%-18s %-10s %-12s %s${RESET}\n" "STACK" "STATUS" "CONTAINERS" "ENV"
|
|
sep
|
|
|
|
for stack in "${all_stacks[@]}"; do
|
|
local stack_dir="$STACKS_DIR/$stack"
|
|
local env_label has_env
|
|
if [[ -f "$stack_dir/.env" ]]; then
|
|
env_label="${GREEN}✓ .env${RESET}"
|
|
has_env=true
|
|
else
|
|
env_label="${YELLOW}✗ .env${RESET}"
|
|
has_env=false
|
|
fi
|
|
|
|
local running="0" total="0"
|
|
{ running=$(docker ps --filter "label=com.docker.compose.project=$stack" -q 2>/dev/null | wc -l); } 2>/dev/null || running="?"
|
|
{ total=$( docker ps -a --filter "label=com.docker.compose.project=$stack" -q 2>/dev/null | wc -l); } 2>/dev/null || total="?"
|
|
running="${running// /}"
|
|
total="${total// /}"
|
|
|
|
local status status_color
|
|
if [[ "$running" == "?" || "$total" == "?" ]]; then
|
|
status="${DIM}unknown${RESET}"
|
|
status_color="$DIM"
|
|
elif [[ "$total" -eq 0 ]]; then
|
|
status="${DIM}stopped${RESET}"
|
|
status_color="$DIM"
|
|
elif [[ "$running" -eq "$total" ]]; then
|
|
status="${GREEN}running${RESET}"
|
|
status_color="$GREEN"
|
|
elif [[ "$running" -eq 0 ]]; then
|
|
status="${RED}down${RESET}"
|
|
status_color="$RED"
|
|
else
|
|
status="${YELLOW}partial${RESET}"
|
|
status_color="$YELLOW"
|
|
fi
|
|
|
|
printf " ${BOLD}%-16s${RESET} %-10b ${status_color}%s/%s${RESET}%-7s %b\n" \
|
|
"$stack" "$status" "$running" "$total" "" "$env_label"
|
|
done
|
|
echo
|
|
}
|
|
|
|
cmd_ps() {
|
|
local -a filters=("$@")
|
|
local -a stacks=()
|
|
mapfile -t stacks < <(match_stacks "${filters[@]}")
|
|
for stack in "${stacks[@]}"; do
|
|
header "$stack"
|
|
run_compose "$stack" ps --format table || true
|
|
done
|
|
}
|
|
|
|
cmd_up() {
|
|
local -a filters=("$@")
|
|
local -a stacks=()
|
|
mapfile -t stacks < <(match_stacks "${filters[@]}")
|
|
log "Starting ${#stacks[@]} stack(s): ${stacks[*]}"
|
|
run_for_stacks stacks up -d
|
|
}
|
|
|
|
cmd_down() {
|
|
local remove_volumes=false
|
|
local -a filters=()
|
|
for arg in "$@"; do
|
|
[[ "$arg" == "--volumes" || "$arg" == "-v" ]] && { remove_volumes=true; continue; }
|
|
filters+=("$arg")
|
|
done
|
|
|
|
local -a stacks=()
|
|
mapfile -t stacks < <(match_stacks "${filters[@]}")
|
|
log "Stopping ${#stacks[@]} stack(s): ${stacks[*]}"
|
|
if $remove_volumes; then
|
|
warn "Removing volumes too (--volumes)"
|
|
run_for_stacks stacks down --volumes
|
|
else
|
|
run_for_stacks stacks down
|
|
fi
|
|
}
|
|
|
|
cmd_restart() {
|
|
local -a filters=("$@")
|
|
local -a stacks=()
|
|
mapfile -t stacks < <(match_stacks "${filters[@]}")
|
|
log "Restarting ${#stacks[@]} stack(s): ${stacks[*]}"
|
|
run_for_stacks stacks restart
|
|
}
|
|
|
|
cmd_pull() {
|
|
local -a filters=("$@")
|
|
local -a stacks=()
|
|
mapfile -t stacks < <(match_stacks "${filters[@]}")
|
|
log "Pulling images for ${#stacks[@]} stack(s): ${stacks[*]}"
|
|
run_for_stacks stacks pull
|
|
}
|
|
|
|
cmd_logs() {
|
|
local -a dc_flags=()
|
|
local -a filters=()
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-f|--follow|-t|--timestamps|--no-color) dc_flags+=("$1"); shift ;;
|
|
-n|--tail) dc_flags+=("$1" "${2:?--tail requires a value}"); shift 2 ;;
|
|
--tail=*) dc_flags+=("$1"); shift ;;
|
|
--since|--until) dc_flags+=("$1" "${2:?$1 requires a value}"); shift 2 ;;
|
|
-*) dc_flags+=("$1"); shift ;;
|
|
*) filters+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
|
|
local -a stacks=()
|
|
mapfile -t stacks < <(match_stacks "${filters[@]}")
|
|
|
|
for stack in "${stacks[@]}"; do
|
|
[[ ${#stacks[@]} -gt 1 ]] && header "$stack"
|
|
run_compose "$stack" logs "${dc_flags[@]}" || true
|
|
done
|
|
}
|
|
|
|
cmd_exec() {
|
|
[[ $# -lt 3 ]] && die "Usage: $SCRIPT_NAME exec STACK SERVICE CMD [ARGS...]"
|
|
local stack="$1" service="$2"; shift 2
|
|
run_compose "$stack" exec "$service" "$@"
|
|
}
|
|
|
|
cmd_run_svc() {
|
|
[[ $# -lt 3 ]] && die "Usage: $SCRIPT_NAME run STACK SERVICE CMD [ARGS...]"
|
|
local stack="$1" service="$2"; shift 2
|
|
run_compose "$stack" run --rm "$service" "$@"
|
|
}
|
|
|
|
cmd_config() {
|
|
local -a filters=("$@")
|
|
local -a stacks=()
|
|
mapfile -t stacks < <(match_stacks "${filters[@]}")
|
|
for stack in "${stacks[@]}"; do
|
|
header "$stack"
|
|
run_compose "$stack" config
|
|
done
|
|
}
|
|
|
|
cmd_images() {
|
|
local -a filters=("$@")
|
|
local -a stacks=()
|
|
mapfile -t stacks < <(match_stacks "${filters[@]}")
|
|
for stack in "${stacks[@]}"; do
|
|
header "$stack"
|
|
run_compose "$stack" images
|
|
done
|
|
}
|
|
|
|
cmd_top() {
|
|
local -a filters=("$@")
|
|
local -a stacks=()
|
|
mapfile -t stacks < <(match_stacks "${filters[@]}")
|
|
for stack in "${stacks[@]}"; do
|
|
header "$stack"
|
|
run_compose "$stack" top || true
|
|
done
|
|
}
|
|
|
|
cmd_stats() {
|
|
local -a filters=("$@")
|
|
local -a stacks=()
|
|
mapfile -t stacks < <(match_stacks "${filters[@]}")
|
|
local -a containers=()
|
|
for stack in "${stacks[@]}"; do
|
|
local -a running=()
|
|
mapfile -t running < <(
|
|
docker ps --filter "label=com.docker.compose.project=$stack" --format '{{.Names}}' 2>/dev/null
|
|
)
|
|
containers+=("${running[@]+"${running[@]}"}")
|
|
done
|
|
if [[ ${#containers[@]} -eq 0 ]]; then
|
|
warn "No running containers for the selected stacks"
|
|
return
|
|
fi
|
|
docker stats "${containers[@]}"
|
|
}
|
|
|
|
# Passthrough: run any docker compose subcommand directly on a stack
|
|
cmd_compose() {
|
|
[[ $# -lt 1 ]] && die "Usage: $SCRIPT_NAME compose STACK [CMD...]"
|
|
local stack="$1"; shift
|
|
run_compose "$stack" "$@"
|
|
}
|
|
|
|
# ─── Update Service ───────────────────────────────────────────────────────────
|
|
|
|
# Resolve each service's image tag to its current local SHA256 ID.
|
|
# Reads from the local image store (updated by 'docker compose pull'),
|
|
# NOT from running containers — so the comparison reflects the pulled image.
|
|
_stack_image_ids() {
|
|
local stack_dir="$1"; shift
|
|
local -a env_flag=("$@")
|
|
local images
|
|
# 'config --images' (compose v2.19+) outputs one image name per line;
|
|
# fall back to parsing the resolved config yaml for older versions.
|
|
if images=$(cd "$stack_dir" && docker compose "${env_flag[@]}" config --images 2>/dev/null) \
|
|
&& [[ -n "$images" ]]; then
|
|
echo "$images"
|
|
else
|
|
cd "$stack_dir" && docker compose "${env_flag[@]}" config 2>/dev/null \
|
|
| awk '/^\s+image:/{print $2}' | sort -u
|
|
fi | xargs -r docker image inspect --format '{{.Id}}' 2>/dev/null | sort
|
|
}
|
|
|
|
_update_run() {
|
|
load_root_env
|
|
|
|
local -a all_stacks=()
|
|
mapfile -t all_stacks < <(list_all_stacks)
|
|
local -a updated=() failed=()
|
|
local -i checked=0
|
|
|
|
log "Starting image update check (${#all_stacks[@]} stacks)"
|
|
|
|
for stack in "${all_stacks[@]}"; do
|
|
local stack_dir="$STACKS_DIR/$stack"
|
|
local -a env_flag=()
|
|
[[ -f "$stack_dir/.env" ]] && env_flag=(--env-file .env)
|
|
|
|
info "Checking ${BOLD}$stack${RESET}"
|
|
((checked++)) || true
|
|
|
|
local before after
|
|
before=$(_stack_image_ids "$stack_dir" "${env_flag[@]+"${env_flag[@]}"}")
|
|
(cd "$stack_dir" && docker compose "${env_flag[@]}" pull 2>&1) || true
|
|
after=$(_stack_image_ids "$stack_dir" "${env_flag[@]+"${env_flag[@]}"}")
|
|
|
|
if [[ "$before" != "$after" ]]; then
|
|
info " Updates found — recreating $stack"
|
|
if (cd "$stack_dir" && docker compose "${env_flag[@]}" up -d 2>&1); then
|
|
updated+=("$stack")
|
|
ok " $stack updated"
|
|
else
|
|
err " Failed to recreate $stack"
|
|
failed+=("$stack")
|
|
fi
|
|
else
|
|
info " Up to date"
|
|
fi
|
|
done
|
|
|
|
log "Pruning dangling images"
|
|
docker image prune -f
|
|
|
|
local date_str
|
|
date_str="$(date '+%Y-%m-%d %H:%M')"
|
|
|
|
if [[ ${#failed[@]} -gt 0 ]]; then
|
|
_notify "#cc0000" "❌ **Update failed** — $date_str\nUpdated: ${updated[*]:-none}\nFailed: ${failed[*]}"
|
|
return 1
|
|
elif [[ ${#updated[@]} -gt 0 ]]; then
|
|
_notify "#36a64f" "✅ **Updates applied** — $date_str\nStacks updated (${#updated[@]}/$checked): ${updated[*]}"
|
|
else
|
|
_notify "#36a64f" "✅ **All up to date** — $date_str ($checked stacks checked)"
|
|
fi
|
|
|
|
log "Update check complete"
|
|
}
|
|
|
|
cmd_update() {
|
|
local sub="${1:-help}"; shift || true
|
|
|
|
case "$sub" in
|
|
install)
|
|
log "Installing update service"
|
|
if $DRY_RUN; then
|
|
info "[dry-run] Would write /etc/systemd/system/stacks-update.{service,timer}"
|
|
info "[dry-run] systemctl enable --now stacks-update.timer"
|
|
return
|
|
fi
|
|
cat > /etc/systemd/system/stacks-update.service << EOF
|
|
[Unit]
|
|
Description=Docker stacks image update
|
|
After=network-online.target docker.service
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
User=root
|
|
ExecStart=${SCRIPT_PATH} update run
|
|
Environment=HOME=/root
|
|
EOF
|
|
cat > /etc/systemd/system/stacks-update.timer << 'EOF'
|
|
[Unit]
|
|
Description=Daily Docker stacks image update at 2:00 AM
|
|
|
|
[Timer]
|
|
OnCalendar=*-*-* 02:00:00 Europe/Amsterdam
|
|
Persistent=true
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
systemctl daemon-reload
|
|
systemctl enable --now stacks-update.timer
|
|
ok "Update service installed and enabled"
|
|
systemctl status stacks-update.timer --no-pager || true
|
|
;;
|
|
|
|
uninstall)
|
|
log "Uninstalling update service"
|
|
if $DRY_RUN; then info "[dry-run] Would uninstall update service"; return; fi
|
|
systemctl disable --now stacks-update.timer 2>/dev/null || true
|
|
rm -f /etc/systemd/system/stacks-update.{service,timer}
|
|
systemctl daemon-reload
|
|
ok "Update service removed"
|
|
;;
|
|
|
|
run)
|
|
log "Running update now"
|
|
if $DRY_RUN; then info "[dry-run] Would run image update across all stacks"; return; fi
|
|
_update_run
|
|
;;
|
|
|
|
status)
|
|
systemctl status stacks-update.timer stacks-update.service --no-pager 2>/dev/null \
|
|
|| warn "Update service not installed"
|
|
;;
|
|
|
|
logs)
|
|
journalctl -u stacks-update.service "${@}" --no-pager 2>/dev/null \
|
|
|| warn "No update logs found (is the service installed?)"
|
|
;;
|
|
|
|
next)
|
|
systemctl list-timers stacks-update.timer --no-pager 2>/dev/null \
|
|
|| warn "Timer not active"
|
|
;;
|
|
|
|
help|"")
|
|
echo "Usage: $SCRIPT_NAME update <subcommand>"
|
|
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 <stack>_db containers by convention ---
|
|
local -a all_stacks=()
|
|
mapfile -t all_stacks < <(list_all_stacks)
|
|
|
|
local -a dump_errors=()
|
|
local -i dumped=0
|
|
|
|
for stack in "${all_stacks[@]}"; do
|
|
local container="${stack}_db"
|
|
# Check the container exists and is running
|
|
if docker ps --filter "name=^/${container}$" --filter "status=running" -q 2>/dev/null | grep -q .; then
|
|
info "Dumping ${BOLD}$stack${RESET} DB ($container, user: $stack, db: $stack)"
|
|
if docker exec "$container" pg_dump -U "$stack" "$stack" \
|
|
> "$dump_dir/${stack}.sql" 2>/dev/null; then
|
|
local size
|
|
size="$(du -h "$dump_dir/${stack}.sql" 2>/dev/null | cut -f1)"
|
|
ok " $stack DB dumped ($size)"
|
|
((dumped++)) || true
|
|
else
|
|
err " Dump failed: $container"
|
|
dump_errors+=("$stack")
|
|
rm -f "$dump_dir/${stack}.sql"
|
|
fi
|
|
fi
|
|
done
|
|
|
|
[[ $dumped -gt 0 || ${#dump_errors[@]} -gt 0 ]] \
|
|
&& info "DB dumps: $dumped ok, ${#dump_errors[@]} failed"
|
|
|
|
# --- Restic backup ---
|
|
log "Running restic backup of $data_dir"
|
|
if ! restic backup "$data_dir" --tag stacks 2>&1; then
|
|
err "FATAL: restic backup failed"
|
|
_notify "#cc0000" "❌ **Backup failed** — restic error. Check \`journalctl -u stacks-backup\`."
|
|
return 1
|
|
fi
|
|
|
|
# --- Prune ---
|
|
log "Pruning old snapshots"
|
|
restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6 2>&1 \
|
|
|| warn "restic forget failed"
|
|
|
|
# --- Summary ---
|
|
local snapshot_info repo_stats summary
|
|
snapshot_info="$(restic snapshots --latest 1 --compact 2>/dev/null | tail -3 | head -1 || true)"
|
|
repo_stats="$(restic stats 2>/dev/null | grep "Total Size" || true)"
|
|
|
|
summary="✅ **Backup complete** — $(date '+%Y-%m-%d %H:%M')"
|
|
[[ $dumped -gt 0 ]] && summary+="\nDB dumps: $dumped stacks"
|
|
[[ ${#dump_errors[@]} -gt 0 ]] && summary+="\n⚠️ Failed dumps: ${dump_errors[*]}"
|
|
[[ -n "$snapshot_info" ]] && summary+="\nLatest: \`$snapshot_info\`"
|
|
[[ -n "$repo_stats" ]] && summary+="\nRepo: $repo_stats"
|
|
|
|
_notify "#36a64f" "$summary"
|
|
log "Backup complete"
|
|
}
|
|
|
|
cmd_backup() {
|
|
local sub="${1:-help}"; shift || true
|
|
|
|
case "$sub" in
|
|
install)
|
|
log "Installing backup service"
|
|
if $DRY_RUN; then
|
|
info "[dry-run] Would write /etc/systemd/system/stacks-backup.{service,timer}"
|
|
info "[dry-run] systemctl enable --now stacks-backup.timer"
|
|
return
|
|
fi
|
|
cat > /etc/systemd/system/stacks-backup.service << EOF
|
|
[Unit]
|
|
Description=Backup stacks data to HiDrive via restic
|
|
After=network-online.target docker.service
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
User=root
|
|
ExecStart=${SCRIPT_PATH} backup run
|
|
Environment=HOME=/root
|
|
EOF
|
|
cat > /etc/systemd/system/stacks-backup.timer << 'EOF'
|
|
[Unit]
|
|
Description=Daily stacks backup at 3:00 AM
|
|
|
|
[Timer]
|
|
OnCalendar=*-*-* 03:00:00 Europe/Amsterdam
|
|
Persistent=true
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
EOF
|
|
systemctl daemon-reload
|
|
systemctl enable --now stacks-backup.timer
|
|
ok "Backup service installed and enabled"
|
|
systemctl status stacks-backup.timer --no-pager || true
|
|
;;
|
|
|
|
uninstall)
|
|
log "Uninstalling backup service"
|
|
if $DRY_RUN; then info "[dry-run] Would uninstall backup service"; return; fi
|
|
systemctl disable --now stacks-backup.timer 2>/dev/null || true
|
|
rm -f /etc/systemd/system/stacks-backup.{service,timer}
|
|
systemctl daemon-reload
|
|
ok "Backup service removed"
|
|
;;
|
|
|
|
run)
|
|
log "Running backup now"
|
|
if $DRY_RUN; then info "[dry-run] Would run restic backup with dynamic DB detection"; return; fi
|
|
_backup_run
|
|
;;
|
|
|
|
status)
|
|
systemctl status stacks-backup.timer stacks-backup.service --no-pager 2>/dev/null \
|
|
|| warn "Backup service not installed"
|
|
;;
|
|
|
|
logs)
|
|
journalctl -u stacks-backup.service "${@}" --no-pager 2>/dev/null \
|
|
|| warn "No backup logs found (is the service installed?)"
|
|
;;
|
|
|
|
next)
|
|
systemctl list-timers stacks-backup.timer --no-pager 2>/dev/null \
|
|
|| warn "Timer not active"
|
|
;;
|
|
|
|
snapshots)
|
|
load_root_env
|
|
[[ -n "${RESTIC_REPOSITORY:-}" ]] || die "RESTIC_REPOSITORY not set in $ROOT_ENV"
|
|
[[ -n "${RESTIC_PASSWORD:-}" ]] || die "RESTIC_PASSWORD not set in $ROOT_ENV"
|
|
export RESTIC_REPOSITORY RESTIC_PASSWORD
|
|
restic snapshots 2>/dev/null || die "restic not installed or repo not initialised"
|
|
;;
|
|
|
|
help|"")
|
|
echo "Usage: $SCRIPT_NAME backup <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() {
|
|
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 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] [--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 "$@"
|