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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d28494d21f
commit
35b1378080
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user