#!/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 < [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