#!/usr/bin/env bash set -uo pipefail shopt -s nullglob SCRIPT_DIR="$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")" STACKS_DIR="${STACKS_DIR:-$SCRIPT_DIR}" CONFIG_FILE="${STACKS_CONFIG:-$STACKS_DIR/manage.conf}" LOCK_FILE="${STACKS_LOCK_FILE:-/run/lock/stacks-manage.lock}" DEFAULT_MAX_PARALLEL=2 CONFIG_GROUP_NAMES=() CONFIG_GROUP_LIMITS=() CONFIG_GROUP_STACKS=() CONFIGURED_STACKS=() EXCLUDED_STACKS=() DISCOVERED_STACKS=() MANAGED_STACKS=() GROUP_NAMES=() GROUP_LIMITS=() GROUP_STACKS=() UNASSIGNED_STACKS=() RUN_SUCCESS=0 RUN_FAILED=0 RUN_FAILED_STACKS=() die() { echo "ERROR: $*" >&2 exit 1 } usage() { cat </dev/null 2>&1; then die "$legacy is no longer supported; migrate $CONFIG_FILE to group/exclude directives" fi done } discover_stacks() { local compose_file stack DISCOVERED_STACKS=() for compose_file in "$STACKS_DIR"/*/compose.yaml; do stack="${compose_file%/compose.yaml}" DISCOVERED_STACKS+=("${stack##*/}") done } is_discovered() { contains "$1" "${DISCOVERED_STACKS[@]}" } is_managed() { is_discovered "$1" && ! contains "$1" "${EXCLUDED_STACKS[@]}" } validate_config_stacks() { local stack for stack in "${CONFIGURED_STACKS[@]}"; do is_discovered "$stack" || die "configured stack not found: $stack" done for stack in "${EXCLUDED_STACKS[@]}"; do is_discovered "$stack" || die "excluded stack not found: $stack" done } build_execution_groups() { local i stack limit local -a stacks=() GROUP_NAMES=() GROUP_LIMITS=() GROUP_STACKS=() MANAGED_STACKS=() UNASSIGNED_STACKS=() for ((i = 0; i < ${#CONFIG_GROUP_NAMES[@]}; i++)); do limit="${CONFIG_GROUP_LIMITS[i]:-$DEFAULT_MAX_PARALLEL}" GROUP_NAMES+=("${CONFIG_GROUP_NAMES[i]}") GROUP_LIMITS+=("$limit") GROUP_STACKS+=("${CONFIG_GROUP_STACKS[i]}") read -r -a stacks <<<"${CONFIG_GROUP_STACKS[i]}" MANAGED_STACKS+=("${stacks[@]}") done for stack in "${DISCOVERED_STACKS[@]}"; do if ! contains "$stack" "${CONFIGURED_STACKS[@]}" && ! contains "$stack" "${EXCLUDED_STACKS[@]}"; then UNASSIGNED_STACKS+=("$stack") MANAGED_STACKS+=("$stack") fi done if ((${#UNASSIGNED_STACKS[@]} > 0)); then GROUP_NAMES+=("unassigned") GROUP_LIMITS+=("$DEFAULT_MAX_PARALLEL") GROUP_STACKS+=("${UNASSIGNED_STACKS[*]}") fi } warn_unassigned() { ((${#UNASSIGNED_STACKS[@]} == 0)) && return 0 echo "WARNING: unassigned stacks will run in the final implicit group: ${UNASSIGNED_STACKS[*]}" >&2 } stack_group() { local target="$1" local i local -a stacks=() for ((i = 0; i < ${#GROUP_NAMES[@]}; i++)); do read -r -a stacks <<<"${GROUP_STACKS[i]}" if contains "$target" "${stacks[@]}"; then echo "${GROUP_NAMES[i]}" return 0 fi done return 1 } compose() { local stack="$1" shift local -a docker_args=(compose) if [[ "${STACKS_FORCE_ANSI:-0}" == "1" ]]; then docker_args+=(--ansi always) fi ( cd -- "$STACKS_DIR/$stack" || exit 1 docker "${docker_args[@]}" "$@" ) } run_stack() { local stack="$1" local action="$2" local show_heading="${3:-1}" if [[ "$show_heading" == "1" ]]; then echo echo "==> $stack: $action" fi case "$action" in up) compose "$stack" config -q && compose "$stack" up -d ;; update) compose "$stack" config -q && compose "$stack" pull && compose "$stack" up -d ;; *) compose "$stack" "$action" ;; esac } run_stack_prefixed() { local stack="$1" local action="$2" local line status STACKS_FORCE_ANSI=1 run_stack "$stack" "$action" 0 2>&1 | while IFS= read -r line || [[ -n "$line" ]]; do [[ -n "$line" ]] || continue printf '[%s] %s\n' "$stack" "$line" done status="${PIPESTATUS[0]}" return "$status" } run_group() { local group_index="$1" local action="$2" local group_name="${GROUP_NAMES[group_index]}" local limit="${GROUP_LIMITS[group_index]}" local semaphore_dir semaphore_fifo semaphore_fd local stack pid i status local failed=0 local -a stacks=() pids=() pid_stacks=() read -r -a stacks <<<"${GROUP_STACKS[group_index]}" ((${#stacks[@]} > 0)) || return 0 if ((limit == 0 || limit > ${#stacks[@]})); then limit="${#stacks[@]}" fi echo echo "==> Group $group_name: $action (${#stacks[@]} stacks, parallel=$limit)" semaphore_dir="$(mktemp -d "${TMPDIR:-/tmp}/stacks-manage.XXXXXX")" || die "cannot create semaphore directory" semaphore_fifo="$semaphore_dir/tokens" mkfifo "$semaphore_fifo" || die "cannot create semaphore" exec {semaphore_fd}<>"$semaphore_fifo" || die "cannot open semaphore" for ((i = 0; i < limit; i++)); do printf '\n' >&"$semaphore_fd" done for stack in "${stacks[@]}"; do IFS= read -r -u "$semaphore_fd" ( status=0 run_stack_prefixed "$stack" "$action" || status=$? printf '\n' >&"$semaphore_fd" exit "$status" ) & pids+=("$!") pid_stacks+=("$stack") done for ((i = 0; i < ${#pids[@]}; i++)); do pid="${pids[i]}" stack="${pid_stacks[i]}" if wait "$pid"; then ((RUN_SUCCESS += 1)) else ((RUN_FAILED += 1)) RUN_FAILED_STACKS+=("$stack") ((failed += 1)) fi done exec {semaphore_fd}>&- rm -f -- "$semaphore_fifo" rmdir -- "$semaphore_dir" ((failed == 0)) } print_run_summary() { echo echo "Succeeded: $RUN_SUCCESS" echo "Failed: $RUN_FAILED" if ((${#RUN_FAILED_STACKS[@]} > 0)); then echo "Failures: ${RUN_FAILED_STACKS[*]}" fi } print_skipped_groups() { local failed_index="$1" local direction="$2" local i local -a skipped=() if [[ "$direction" == "reverse" ]]; then for ((i = failed_index - 1; i >= 0; i--)); do skipped+=("${GROUP_NAMES[i]}") done else for ((i = failed_index + 1; i < ${#GROUP_NAMES[@]}; i++)); do skipped+=("${GROUP_NAMES[i]}") done fi if ((${#skipped[@]} > 0)); then echo "Skipped groups after failure: ${skipped[*]}" >&2 fi } run_all_grouped() { local action="$1" local direction="forward" local i RUN_SUCCESS=0 RUN_FAILED=0 RUN_FAILED_STACKS=() warn_unassigned if [[ "$action" == "stop" || "$action" == "down" ]]; then direction="reverse" for ((i = ${#GROUP_NAMES[@]} - 1; i >= 0; i--)); do if ! run_group "$i" "$action"; then print_skipped_groups "$i" "$direction" print_run_summary return 1 fi done else for ((i = 0; i < ${#GROUP_NAMES[@]}; i++)); do if ! run_group "$i" "$action"; then print_skipped_groups "$i" "$direction" print_run_summary return 1 fi done fi print_run_summary } run_all_sequential() { local action="$1" local stack RUN_SUCCESS=0 RUN_FAILED=0 RUN_FAILED_STACKS=() warn_unassigned for stack in "${MANAGED_STACKS[@]}"; do if run_stack "$stack" "$action"; then ((RUN_SUCCESS += 1)) else ((RUN_FAILED += 1)) RUN_FAILED_STACKS+=("$stack") fi done print_run_summary ((RUN_FAILED == 0)) } list_stacks() { local stack group_name status warn_unassigned printf '%-24s %-16s %s\n' "STACK" "GROUP" "STATUS" printf '%-24s %-16s %s\n' "------------------------" "----------------" "---------------" for stack in "${DISCOVERED_STACKS[@]}"; do if contains "$stack" "${EXCLUDED_STACKS[@]}"; then group_name="-" status="excluded" else group_name="$(stack_group "$stack")" status="managed" fi printf '%-24s %-16s %s\n' "$stack" "$group_name" "$status" done } stack_images() { local stack="$1" local output image output="$(compose "$stack" config --images)" || return 1 while IFS= read -r image; do [[ -n "$image" ]] || continue printf '%-24s %s\n' "$stack" "$image" done <<<"$output" } list_images() { local target="${1:-}" local stack local failed=0 printf '%-24s %s\n' "STACK" "IMAGE" printf '%-24s %s\n' "------------------------" "----------------------------------------" if [[ -n "$target" ]]; then require_target "images" "$target" stack_images "$target" return fi warn_unassigned for stack in "${MANAGED_STACKS[@]}"; do stack_images "$stack" || ((failed += 1)) done ((failed == 0)) } require_target() { local command="$1" local target="$2" [[ -n "$target" ]] || die "$command requires a stack name" is_discovered "$target" || die "stack not found: $target" is_managed "$target" || die "stack is excluded: $target" } acquire_lock() { mkdir -p "$(dirname "$LOCK_FILE")" || die "cannot create lock directory" exec {LOCK_FD}>"$LOCK_FILE" || die "cannot open lock file: $LOCK_FILE" flock -n "$LOCK_FD" || die "another manage.sh operation is already running" } load_config discover_stacks validate_config_stacks build_execution_groups ACTION="${1:-}" TARGET="${2:-}" [[ $# -le 2 ]] || die "too many arguments" case "$ACTION" in up | start | stop | restart | down | pull | update) acquire_lock if [[ -n "$TARGET" ]]; then require_target "$ACTION" "$TARGET" run_stack "$TARGET" "$ACTION" else run_all_grouped "$ACTION" fi ;; ps) if [[ -n "$TARGET" ]]; then require_target "$ACTION" "$TARGET" run_stack "$TARGET" "$ACTION" else run_all_sequential "$ACTION" fi ;; logs) require_target "$ACTION" "$TARGET" compose "$TARGET" logs -f --tail=100 ;; config) require_target "$ACTION" "$TARGET" compose "$TARGET" config ;; images) list_images "$TARGET" ;; list) [[ -z "$TARGET" ]] || die "list does not accept a stack name" list_stacks ;; help | -h | --help) usage ;; *) usage exit 1 ;; esac