From fdcc47cb5bd2574bd95d1b4dfa58da1b0cb16c0e Mon Sep 17 00:00:00 2001 From: lingkai5wu Date: Sat, 10 Oct 2026 20:14:35 +0800 Subject: [PATCH] fix: harden stack validation and lifecycle execution --- manage.conf | 7 +- manage.sh | 220 ++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 158 insertions(+), 69 deletions(-) diff --git a/manage.conf b/manage.conf index a593340..006c149 100644 --- a/manage.conf +++ b/manage.conf @@ -1,7 +1,12 @@ # Maximum number of stacks executed concurrently within each stage. -# Use 0 for unlimited concurrency. +# Use 0 for unlimited concurrency. Range: 0-65535; no leading zeros. max_parallel 3 +# Wait for running/healthy services on up, start, and update. +# Healthchecks are needed to verify application readiness. +# Range: 0-86400 seconds; 0 disables waiting. Default: 120 seconds. +wait_timeout 120 + # Each declaration is an ordered stage; stacks on the same line run in parallel. # Add --parallel N before the stacks to override max_parallel for that stage. # Example: before --parallel 2 stack-a stack-b diff --git a/manage.sh b/manage.sh index db8daba..a8fcd1d 100755 --- a/manage.sh +++ b/manage.sh @@ -9,6 +9,7 @@ CONFIG_FILE="${STACKS_CONFIG:-$STACKS_DIR/manage.conf}" LOCK_FILE="${STACKS_LOCK_FILE:-/run/lock/stacks-manage.lock}" DEFAULT_MAX_PARALLEL=2 +WAIT_TIMEOUT=120 BEFORE_STAGES=() BEFORE_LIMITS=() @@ -40,19 +41,32 @@ fi handle_signal() { local status="$1" + local pid attempt alive local -a pids=() - trap - INT TERM - mapfile -t pids < <(jobs -pr) - ((${#pids[@]} == 0)) || kill -TERM "${pids[@]}" 2>/dev/null - wait 2>/dev/null + trap '' INT TERM + mapfile -t pids < <(jobs -p) + for pid in "${pids[@]}"; do + kill -TERM -- "-$pid" 2>/dev/null || : + done + + # Allow five seconds for cleanup, then stop every remaining descendant. + for ((attempt = 0; attempt < 50; attempt++)); do + alive=0 + for pid in "${pids[@]}"; do + kill -0 -- "-$pid" 2>/dev/null && alive=1 + done + ((alive)) || break + sleep 0.1 + done + for pid in "${pids[@]}"; do + kill -KILL -- "-$pid" 2>/dev/null || : + done + ((${#pids[@]} == 0)) || wait "${pids[@]}" 2>/dev/null printf '\n' exit "$status" } -trap 'handle_signal 130' INT -trap 'handle_signal 143' TERM - usage() { cat < default -> after, reversed for stop/down. A failed stage skips all remaining stages. + up/start/update wait for running/healthy services when waiting is enabled. + Services need healthchecks to verify readiness beyond the running state. + up/update validate every selected Compose configuration before making changes. + Numeric settings do not accept leading zeros. Config: $CONFIG_FILE EOF @@ -100,11 +120,24 @@ contains() { return 1 } -validate_parallel_limit() { - local value="$1" +validate_integer_range() { + local kind="$1" + local value="$2" + local maximum="$3" - [[ "$value" =~ ^[0-9]+$ ]] || - die "parallel limit must be a non-negative integer: $value" + [[ "$value" =~ ^(0|[1-9][0-9]*)$ ]] && + ((${#value} <= ${#maximum})) && ((value <= maximum)) || + die "$kind must be an integer between 0 and $maximum without leading zeros: $value" +} + +validate_parallel_limit() { + validate_integer_range "parallel limit" "$1" 65535 +} + +wait_timeout() { + [[ $# -eq 1 ]] || die "wait_timeout expects exactly one value" + validate_integer_range "wait timeout" "$1" 86400 + WAIT_TIMEOUT="$1" } validate_name() { @@ -175,7 +208,7 @@ exclude() { load_config() { [[ -f "$CONFIG_FILE" ]] || return 0 - # The trusted config calls max_parallel, before, after, and exclude above. + # The trusted config calls the configuration functions above. source "$CONFIG_FILE" || die "cannot load config file: $CONFIG_FILE" } @@ -186,7 +219,9 @@ discover_stacks() { for compose_file in "$STACKS_DIR"/*/compose.yaml; do stack="${compose_file%/compose.yaml}" - DISCOVERED_STACKS+=("${stack##*/}") + stack="${stack##*/}" + validate_name "stack directory" "$stack" + DISCOVERED_STACKS+=("$stack") done } @@ -293,6 +328,11 @@ run_stack() { local stack="$1" local action="$2" local show_heading="${3:-1}" + local -a wait_args=() + + if ((WAIT_TIMEOUT > 0)); then + wait_args=(--wait --wait-timeout "$WAIT_TIMEOUT") + fi if [[ "$show_heading" == "1" ]]; then echo @@ -301,13 +341,14 @@ run_stack() { case "$action" in up) - compose "$stack" config -q && - compose "$stack" up -d + compose "$stack" up -d "${wait_args[@]}" + ;; + start) + compose "$stack" start "${wait_args[@]}" ;; update) - compose "$stack" config -q && - compose "$stack" pull && - compose "$stack" up -d + compose "$stack" pull && + compose "$stack" up -d "${wait_args[@]}" ;; *) compose "$stack" "$action" @@ -315,6 +356,21 @@ run_stack() { esac } +preflight_configs() { + local stack + local -a failed_stacks=() + + (($# > 0)) || return 0 + printf '\n==> Checking Compose configurations\n' + for stack in "$@"; do + compose "$stack" config -q || failed_stacks+=("$stack") + done + if ((${#failed_stacks[@]} > 0)); then + printf 'ERROR: invalid Compose configuration: %s\n' "${failed_stacks[*]}" >&2 + return 1 + fi +} + run_stage() { local stage_index="$1" local action="$2" @@ -596,59 +652,87 @@ acquire_lock() { die "another manage.sh operation is already running" } -ACTION="${1:-}" -TARGET="${2:-}" +main() { + local ACTION="${1:-}" + local TARGET="${2:-}" + local -a targets=() -[[ $# -le 2 ]] || die "too many arguments" + [[ $# -le 2 ]] || die "too many arguments" -case "$ACTION" in -help | -h | --help) - usage - exit 0 - ;; -esac + case "$ACTION" in + help | -h | --help) + usage + exit 0 + ;; + esac -load_config -discover_stacks -validate_config_stacks -build_execution_stages + load_config + discover_stacks + validate_config_stacks + build_execution_stages -case "$ACTION" in -up | start | stop | restart | down | pull | update) - acquire_lock + case "$ACTION" in + up | start | stop | restart | down | pull | update) + acquire_lock - if [[ -n "$TARGET" ]]; then + if [[ -n "$TARGET" ]]; then + require_target "$ACTION" "$TARGET" + targets=("$TARGET") + else + targets=("${MANAGED_STACKS[@]}") + fi + if [[ "$ACTION" == "up" || "$ACTION" == "update" ]]; then + preflight_configs "${targets[@]}" || return 1 + fi + + if [[ -n "$TARGET" ]]; then + run_stack "$TARGET" "$ACTION" + else + run_all_staged "$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" - run_stack "$TARGET" "$ACTION" - else - run_all_staged "$ACTION" - fi - ;; -ps) - if [[ -n "$TARGET" ]]; then + compose "$TARGET" logs -f --tail=100 + ;; + config) 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 - ;; -*) - usage - exit 1 - ;; -esac + compose "$TARGET" config + ;; + images) + list_images "$TARGET" + ;; + list) + [[ -z "$TARGET" ]] || die "list does not accept a stack name" + list_stacks + ;; + *) + usage + exit 1 + ;; + esac +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + trap 'handle_signal 130' INT + trap 'handle_signal 143' TERM + # One isolated process group covers the scheduler and all Docker descendants. + # Disable job control inside it so parallel workers stay in the same group. + set -m + ( + set +m + trap - INT TERM + main "$@" + ) <&0 & + main_pid=$! + set +m + wait "$main_pid" +fi