2026-06-16 19:13:43 +02:00
|
|
|
#!/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() {
|
2026-06-16 19:52:55 +02:00
|
|
|
local stacks_init="$1" # pre-rendered stacks array initialisation block
|
2026-06-16 19:13:43 +02:00
|
|
|
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]}"
|
2026-06-16 19:52:55 +02:00
|
|
|
EOF
|
|
|
|
|
echo "$stacks_init"
|
|
|
|
|
cat << 'EOF'
|
2026-06-16 19:13:43 +02:00
|
|
|
|
|
|
|
|
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 ;;
|
2026-06-16 19:52:55 +02:00
|
|
|
completion) COMPREPLY=( $(compgen -W "bash zsh --install --static" -- "$cur") ); return ;;
|
2026-06-16 19:13:43 +02:00
|
|
|
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() {
|
2026-06-16 19:52:55 +02:00
|
|
|
local stacks_init="$1" # pre-rendered stacks array initialisation block
|
2026-06-16 19:13:43 +02:00
|
|
|
cat << 'EOF'
|
|
|
|
|
#compdef stacks.sh stacks
|
|
|
|
|
|
|
|
|
|
_stacks() {
|
|
|
|
|
local script="${words[1]}"
|
2026-06-16 19:52:55 +02:00
|
|
|
EOF
|
|
|
|
|
echo "$stacks_init"
|
|
|
|
|
cat << 'EOF'
|
2026-06-16 19:13:43 +02:00
|
|
|
|
|
|
|
|
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]' \
|
2026-06-16 19:52:55 +02:00
|
|
|
'--static[Bake current stack list into completion script]' \
|
2026-06-16 19:13:43 +02:00
|
|
|
'1:shell:(bash zsh)'
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_stacks "$@"
|
|
|
|
|
EOF
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cmd_completion() {
|
2026-06-16 19:52:55 +02:00
|
|
|
local shell="" install=false static=false
|
2026-06-16 19:13:43 +02:00
|
|
|
|
|
|
|
|
for arg in "$@"; do
|
|
|
|
|
case "$arg" in
|
|
|
|
|
bash|zsh) shell="$arg" ;;
|
|
|
|
|
--install) install=true ;;
|
2026-06-16 19:52:55 +02:00
|
|
|
--static) static=true ;;
|
|
|
|
|
*) die "Unknown argument: $arg (bash|zsh [--install] [--static])" ;;
|
2026-06-16 19:13:43 +02:00
|
|
|
esac
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
if [[ -z "$shell" ]]; then
|
|
|
|
|
shell="$(basename "${SHELL:-bash}")"
|
|
|
|
|
[[ "$shell" == "bash" || "$shell" == "zsh" ]] || shell="bash"
|
|
|
|
|
warn "No shell specified, defaulting to: $shell"
|
|
|
|
|
fi
|
|
|
|
|
|
2026-06-16 19:52:55 +02:00
|
|
|
# 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
|
|
|
|
|
|
2026-06-16 19:13:43 +02:00
|
|
|
local content
|
|
|
|
|
case "$shell" in
|
2026-06-16 19:52:55 +02:00
|
|
|
bash) content="$(_completion_bash "$bash_stacks_init")" ;;
|
|
|
|
|
zsh) content="$(_completion_zsh "$zsh_stacks_init")" ;;
|
2026-06-16 19:13:43 +02:00
|
|
|
*) 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
|
2026-06-16 19:52:55 +02:00
|
|
|
${CYAN}completion${RESET} bash|zsh [--install] [--static] Generate/install shell completion
|
|
|
|
|
--static Bake current stack list in (no runtime discovery)
|
2026-06-16 19:13:43 +02:00
|
|
|
${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 "$@"
|