Files
config-public/server/shared/components/stackctl
T
Lucas WintherandClaude Opus 5.5 35b1378080
lint / yamllint + ansible-lint + syntax (push) Successful in 56s
lint / shellcheck (push) Successful in 7s
lint / secret scan (push) Successful in 2s
Add stackctl for running stacks by hand on a host
One shared script installed into /srv/stacks on every host, with per-host
groups in server/<host>/stack-groups. Tested in check.sh. Also two new
common mistakes in the adding-a-stack skill.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 17:49:12 +02:00

195 lines
5.6 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Start, stop and inspect the stacks under this directory by hand, one at a time
# or by group. Installed to /srv/stacks on every host by
# ansible/playbooks/stackctl.yml; each host's groups live next to it in
# stack-groups.
#
# stackctl up apps # every stack in the apps group
# stackctl -y down all # everything, without asking about openbao
# stackctl pull static-site && stackctl up static-site
# stackctl ps # running containers per stack
# stackctl logs webapp
#
# Each stack is plain `docker compose` in its own directory: the default compose
# file, the .env beside it, the directory name as the project name, and PWD set
# to the directory. That is what an Ansible deploy does for any stack that
# leaves stack_files and stack_env at their defaults, or sets only
# COMPOSE_PROJECT_NAME to the directory name. A stack that relies on anything
# else from those two is not started the same way here.
#
# Ansible remains how stacks are deployed; this is for working on the host.
set -euo pipefail
root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
groups_file="$root/stack-groups"
usage() {
cat >&2 <<EOF
usage: stackctl [-y] <command> [target...]
commands:
up docker compose up -d
down docker compose down
restart docker compose restart
pull docker compose pull
ps running containers per stack (default target: all)
logs follow the logs of exactly one stack
list the groups and every stack found
target: a stack, a group from $groups_file, or "all".
-y skips the confirmation before down or restart of a stack under "confirm:".
EOF
exit 2
}
die() { echo "stackctl: $*" >&2; exit "${2:-2}"; }
warn() { echo "stackctl: $*" >&2; }
yes=false
while getopts ':yh' opt; do
case "$opt" in
y) yes=true ;;
*) usage ;;
esac
done
shift $((OPTIND - 1))
[ $# -ge 1 ] || usage
command="$1"
shift
case "$command" in
up | down | restart | pull | logs) ;;
ps | list) [ $# -ge 1 ] || set -- all ;;
*) warn "unknown command: $command"; usage ;;
esac
[ $# -ge 1 ] || die "$command needs a target: a stack, a group, or all"
# Every directory holding a compose file is a stack.
stacks=()
for dir in "$root"/*/; do
[ -f "$dir/docker-compose.yml" ] && stacks+=("$(basename "$dir")")
done
is_stack() {
local s
for s in "${stacks[@]}"; do [ "$s" = "$1" ] && return 0; done
return 1
}
# stack-groups: "name: stack stack ...", one group per line. A name given on
# several lines collects all of them. "order" and "confirm" are reserved.
declare -A group
order=()
confirm=()
if [ -f "$groups_file" ]; then
while IFS= read -r line || [ -n "$line" ]; do
line="${line%%#*}"
[[ "$line" == *:* ]] || continue
name="$(echo "${line%%:*}" | tr -d '[:space:]')"
read -ra members <<<"${line#*:}"
case "$name" in
order) order+=("${members[@]}") ;;
confirm) confirm+=("${members[@]}") ;;
*) group[$name]="${group[$name]:-} ${members[*]}" ;;
esac
done <"$groups_file"
fi
# Resolve targets to stacks, without duplicates.
declare -A chosen
for target in "$@"; do
case "$target" in
all)
for s in "${stacks[@]}"; do chosen[$s]=1; done
;;
order | confirm)
die "\"$target\" is reserved in stack-groups, not a target"
;;
*)
if [ -n "${group[$target]+x}" ]; then
for s in ${group[$target]}; do
if is_stack "$s"; then
chosen[$s]=1
else
warn "group $target names $s, which has no compose file here; skipping"
fi
done
elif is_stack "$target"; then
chosen[$target]=1
else
die "no stack or group called $target (stackctl list shows both)"
fi
;;
esac
done
# Stacks under "order:" first, in that order, then the rest alphabetically.
rank() {
local i
for i in "${!order[@]}"; do
[ "${order[$i]}" = "$1" ] && { printf '%04d' "$i"; return; }
done
printf '9999'
}
mapfile -t selected < <(
for s in "${!chosen[@]}"; do printf '%s %s\n' "$(rank "$s")" "$s"; done |
sort -k1,1n -k2,2 | cut -d' ' -f2
)
if [ "$command" = down ]; then
mapfile -t selected < <(printf '%s\n' "${selected[@]}" | tac)
fi
[ "${#selected[@]}" -gt 0 ] || die "nothing to do" 1
case "$command" in
list)
for name in $(printf '%s\n' "${!group[@]}" | sort); do
printf '%-10s%s\n' "$name:" "${group[$name]}"
done
printf '%-10s%s\n' "stacks:" "${selected[*]}"
exit 0
;;
logs)
[ "${#selected[@]}" -eq 1 ] || die "logs follows exactly one stack"
cd "$root/${selected[0]}"
exec docker compose logs -f --tail=100
;;
ps)
for s in "${selected[@]}"; do
states="$(cd "$root/$s" && docker compose ps -a --format '{{.State}}')"
total="$(grep -c . <<<"$states" || true)"
running="$(grep -c '^running$' <<<"$states" || true)"
printf '%-24s %s/%s\n' "$s" "$running" "$total"
done
exit 0
;;
esac
# Ask before stopping anything under "confirm:" — restarting OpenBao seals it.
if [ "$command" = down ] || [ "$command" = restart ]; then
for s in "${selected[@]}"; do
for c in "${confirm[@]}"; do
[ "$s" = "$c" ] || continue
$yes && continue
[ -t 0 ] || die "$command $s needs confirmation; rerun with -y" 1
read -rp "$command $s? [y/N] " answer
[[ "$answer" == [yY]* ]] || die "not confirmed; nothing done" 1
done
done
fi
case "$command" in
up) args=(up -d) ;;
*) args=("$command") ;;
esac
failed=()
for s in "${selected[@]}"; do
echo "== $command $s"
(cd "$root/$s" && docker compose "${args[@]}") || failed+=("$s")
done
if [ "${#failed[@]}" -gt 0 ]; then
die "failed: ${failed[*]}" 1
fi