|
#!/usr/bin/env bash
|
|
#
|
|
# run-parallel-cmds.sh — Execute commands in parallel via GNU parallel
|
|
# with structured output, logging, and progress.
|
|
#
|
|
# Usage:
|
|
# run-parallel-cmds.sh [OPTIONS] [--] "cmd1" "cmd2" ...
|
|
# run-parallel-cmds.sh [OPTIONS] -f commands.txt
|
|
# echo -e "cmd1\ncmd2" | run-parallel-cmds.sh [OPTIONS]
|
|
#
|
|
# Requires: GNU parallel
|
|
|
|
set -uo pipefail
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Defaults
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
TIMEOUT=30
|
|
PREPEND=""
|
|
APPEND=""
|
|
SHOW_LOGS=false
|
|
TAIL_LINES=0
|
|
SHOW_EXIT_CODE=true
|
|
FORMAT="json" # json | text
|
|
SHORT_OUTPUT=false
|
|
OUTPUT_FILE=""
|
|
MAX_JOBS=0 # 0 = let parallel decide (CPU count)
|
|
NO_COLOR=false
|
|
declare -a COMMANDS=()
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Colors (set once after arg parsing)
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
RED="" GRN="" YLW="" BLU="" CYN="" BLD="" DIM="" RST=""
|
|
|
|
init_colors() {
|
|
if [[ "$NO_COLOR" == true ]] || [[ ! -t 2 ]]; then
|
|
return
|
|
fi
|
|
RED=$'\033[31m' GRN=$'\033[32m' YLW=$'\033[33m'
|
|
BLU=$'\033[34m' CYN=$'\033[36m' BLD=$'\033[1m'
|
|
DIM=$'\033[2m' RST=$'\033[0m'
|
|
}
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Helpers
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
die() { printf '%s\n' "${RED}Error:${RST} $*" >&2; exit 1; }
|
|
|
|
fmt_elapsed() {
|
|
local s=$1
|
|
if (( s >= 3600 )); then printf '%dh%02dm%02ds' $((s/3600)) $((s%3600/60)) $((s%60))
|
|
elif (( s >= 60 )); then printf '%dm%02ds' $((s/60)) $((s%60))
|
|
else printf '%ds' "$s"
|
|
fi
|
|
}
|
|
|
|
# Pure-bash JSON string escape (handles \, ", newline, CR, tab)
|
|
json_escape() {
|
|
local s="$1"
|
|
s="${s//\\/\\\\}"
|
|
s="${s//\"/\\\"}"
|
|
s="${s//$'\n'/\\n}"
|
|
s="${s//$'\r'/\\r}"
|
|
s="${s//$'\t'/\\t}"
|
|
printf '%s' "\"$s\""
|
|
}
|
|
|
|
strip_ansi() { sed $'s/\033\[[0-9;]*m//g'; }
|
|
|
|
progress_bar() {
|
|
local pct=$1 width=${2:-30}
|
|
local filled=$(( pct * width / 100 ))
|
|
local empty=$(( width - filled ))
|
|
printf '%s' "${GRN}"
|
|
printf '█%.0s' $(seq 1 $filled 2>/dev/null) || true
|
|
printf '%s' "${DIM}"
|
|
printf '░%.0s' $(seq 1 $empty 2>/dev/null) || true
|
|
printf '%s %3d%%' "${RST}" "$pct"
|
|
}
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Usage
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: run-parallel-cmds.sh [OPTIONS] [--] "cmd1" "cmd2" ...
|
|
run-parallel-cmds.sh [OPTIONS] -f commands.txt
|
|
echo -e "cmd1\ncmd2" | run-parallel-cmds.sh [OPTIONS]
|
|
|
|
Options:
|
|
-t, --timeout SEC Timeout per command (default: 30)
|
|
--prepend STR Prepend string to every command
|
|
--append STR Append string to every command
|
|
--logs Include stdout/stderr in output (default: off)
|
|
--tail N Keep only last N log lines (implies --logs)
|
|
-e, --no-exit-code Omit exit codes from output
|
|
--format FMT Output format: json | text (default: json)
|
|
--short Minimal stdout; save full output to file
|
|
-o, --output-file PATH File path for --short mode (default: $PWD/parallel-results.<ts>.<ext>)
|
|
-j, --jobs N Max parallel jobs (default: CPU count)
|
|
-f, --file FILE Read commands from file, one per line
|
|
--no-color Disable colored output
|
|
-h, --help Show this help
|
|
|
|
Examples:
|
|
# Run three commands with 10s timeout, show logs
|
|
run-parallel-cmds.sh -t 10 --logs "sleep 2 && echo ok" "false" "echo hi"
|
|
|
|
# Prepend sudo to every command, text output
|
|
run-parallel-cmds.sh --prepend sudo --format text "systemctl status nginx" "systemctl status redis"
|
|
|
|
# Pipe commands, short output saved to file
|
|
cat urls.txt | run-parallel-cmds.sh --prepend "curl -sS" --short -o results.json
|
|
EOF
|
|
}
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Parse arguments
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-t|--timeout) TIMEOUT="$2"; shift 2 ;;
|
|
--prepend) PREPEND="$2"; shift 2 ;;
|
|
--append) APPEND="$2"; shift 2 ;;
|
|
--logs) SHOW_LOGS=true; shift ;;
|
|
--tail) TAIL_LINES="$2"; SHOW_LOGS=true; shift 2 ;;
|
|
-e|--no-exit-code) SHOW_EXIT_CODE=false; shift ;;
|
|
--format) FORMAT="$2"; shift 2 ;;
|
|
--short) SHORT_OUTPUT=true; shift ;;
|
|
-o|--output-file) OUTPUT_FILE="$2"; shift 2 ;;
|
|
-j|--jobs) MAX_JOBS="$2"; shift 2 ;;
|
|
--no-color) NO_COLOR=true; shift ;;
|
|
-f|--file)
|
|
while IFS= read -r _line; do
|
|
[[ -z "$_line" || "$_line" =~ ^[[:space:]]*# ]] && continue
|
|
COMMANDS+=("$_line")
|
|
done < "$2"
|
|
shift 2 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
--) shift; COMMANDS+=("$@"); break ;;
|
|
-*) printf 'Unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
|
*) COMMANDS+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
|
|
# Read from stdin when no commands given and stdin is piped
|
|
if [[ ${#COMMANDS[@]} -eq 0 ]] && [[ ! -t 0 ]]; then
|
|
while IFS= read -r _line; do
|
|
[[ -z "$_line" || "$_line" =~ ^[[:space:]]*# ]] && continue
|
|
COMMANDS+=("$_line")
|
|
done
|
|
fi
|
|
|
|
# -o without --short implies --short
|
|
[[ -n "$OUTPUT_FILE" ]] && SHORT_OUTPUT=true
|
|
|
|
init_colors
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Validate
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
command -v parallel &>/dev/null || die "GNU parallel is required but not found.
|
|
Install: apt install parallel | brew install parallel | pacman -S parallel"
|
|
|
|
[[ ${#COMMANDS[@]} -eq 0 ]] && { die "No commands provided. See --help."; }
|
|
|
|
case "$FORMAT" in
|
|
json|text) ;;
|
|
*) die "Unknown format '$FORMAT'. Use json or text." ;;
|
|
esac
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Prepare workspace
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
WORK_DIR=$(mktemp -d)
|
|
trap 'rm -rf "$WORK_DIR"' EXIT
|
|
|
|
JOBLOG="$WORK_DIR/joblog"
|
|
OUT_DIR="$WORK_DIR/out"
|
|
mkdir -p "$OUT_DIR"
|
|
|
|
# Build constructed commands (apply prepend / append)
|
|
declare -a CONSTRUCTED=()
|
|
for cmd in "${COMMANDS[@]}"; do
|
|
built=""
|
|
[[ -n "$PREPEND" ]] && built+="${PREPEND} "
|
|
built+="$cmd"
|
|
[[ -n "$APPEND" ]] && built+=" ${APPEND}"
|
|
CONSTRUCTED+=("$built")
|
|
done
|
|
|
|
TOTAL=${#CONSTRUCTED[@]}
|
|
|
|
# Write one script per command (avoids quoting issues in parallel)
|
|
for i in "${!CONSTRUCTED[@]}"; do
|
|
printf '%s\n' "${CONSTRUCTED[$i]}" > "$WORK_DIR/cmd_$((i+1)).sh"
|
|
done
|
|
|
|
# Wrapper: runs a numbered command and captures stdout/stderr to files
|
|
cat > "$WORK_DIR/_run.sh" << 'WRAPPER'
|
|
#!/usr/bin/env bash
|
|
idx="$1"; dir="$2"
|
|
bash "$dir/cmd_${idx}.sh" \
|
|
>"$dir/out/${idx}.stdout" \
|
|
2>"$dir/out/${idx}.stderr"
|
|
WRAPPER
|
|
chmod +x "$WORK_DIR/_run.sh"
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Execute
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
printf '\n' >&2
|
|
printf '%s\n' \
|
|
"${BLD}${CYN}▶ Running ${TOTAL} command(s) in parallel${RST} ${DIM}timeout=${TIMEOUT}s${RST}" >&2
|
|
printf '%s\n' "${DIM}$(printf '─%.0s' $(seq 1 50))${RST}" >&2
|
|
|
|
# Assemble parallel flags
|
|
PARALLEL_ARGS=(
|
|
--timeout "$TIMEOUT"
|
|
--joblog "$JOBLOG"
|
|
--halt never
|
|
)
|
|
[[ -t 2 ]] && PARALLEL_ARGS+=(--bar)
|
|
(( MAX_JOBS > 0 )) && PARALLEL_ARGS+=(-j "$MAX_JOBS")
|
|
|
|
START_EPOCH=$(date +%s)
|
|
|
|
seq 1 "$TOTAL" | parallel "${PARALLEL_ARGS[@]}" \
|
|
"bash \"$WORK_DIR/_run.sh\" {} \"$WORK_DIR\"" || true
|
|
|
|
END_EPOCH=$(date +%s)
|
|
ELAPSED=$(( END_EPOCH - START_EPOCH ))
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Parse job log
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
declare -A EXIT_CODES=() SIGNALS=() JOB_TIMES=()
|
|
SUCCEEDED=0 FAILED=0 TIMED_OUT=0
|
|
|
|
while IFS=$'\t' read -r seq host starttime runtime send receive exitval signal _rest; do
|
|
[[ "$seq" == "Seq" ]] && continue # header row
|
|
EXIT_CODES[$seq]="$exitval"
|
|
SIGNALS[$seq]="$signal"
|
|
JOB_TIMES[$seq]="$runtime"
|
|
|
|
if [[ "$signal" -ne 0 ]]; then
|
|
(( TIMED_OUT++ )); (( FAILED++ ))
|
|
elif [[ "$exitval" -ne 0 ]]; then
|
|
(( FAILED++ ))
|
|
else
|
|
(( SUCCEEDED++ ))
|
|
fi
|
|
done < "$JOBLOG"
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Collect logs for a command index (1-based)
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
get_logs() {
|
|
local idx=$1 combined=""
|
|
local f_out="$OUT_DIR/${idx}.stdout"
|
|
local f_err="$OUT_DIR/${idx}.stderr"
|
|
|
|
[[ -s "$f_out" ]] && combined=$(< "$f_out")
|
|
if [[ -s "$f_err" ]]; then
|
|
[[ -n "$combined" ]] && combined+=$'\n'
|
|
combined+=$(< "$f_err")
|
|
fi
|
|
|
|
if (( TAIL_LINES > 0 )) && [[ -n "$combined" ]]; then
|
|
combined=$(printf '%s' "$combined" | tail -n "$TAIL_LINES")
|
|
fi
|
|
|
|
printf '%s' "$combined"
|
|
}
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Format: JSON
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
format_json() {
|
|
local buf
|
|
buf=$'{\n "results": [\n'
|
|
|
|
local i
|
|
for (( i = 1; i <= TOTAL; i++ )); do
|
|
local cmd="${CONSTRUCTED[$((i-1))]}"
|
|
local ec="${EXIT_CODES[$i]:-255}"
|
|
local sig="${SIGNALS[$i]:-0}"
|
|
local is_timeout="false"
|
|
(( sig != 0 )) && is_timeout="true"
|
|
|
|
buf+=" {"
|
|
buf+=$'\n'" \"command\": $(json_escape "$cmd")"
|
|
|
|
if [[ "$SHOW_EXIT_CODE" == true ]]; then
|
|
buf+=","$'\n'" \"exit_code\": ${ec}"
|
|
[[ "$is_timeout" == "true" ]] && \
|
|
buf+=","$'\n'" \"timed_out\": true"
|
|
fi
|
|
|
|
if [[ "$SHOW_LOGS" == true ]]; then
|
|
local logs
|
|
logs=$(get_logs "$i")
|
|
buf+=","$'\n'" \"logs\": $(json_escape "$logs")"
|
|
fi
|
|
|
|
buf+=$'\n'" }"
|
|
(( i < TOTAL )) && buf+=","
|
|
buf+=$'\n'
|
|
done
|
|
|
|
buf+=' ],'$'\n'
|
|
buf+=' "summary": {'$'\n'
|
|
buf+=" \"total\": ${TOTAL},"$'\n'
|
|
buf+=" \"succeeded\": ${SUCCEEDED},"$'\n'
|
|
buf+=" \"failed\": ${FAILED},"$'\n'
|
|
buf+=" \"timed_out\": ${TIMED_OUT},"$'\n'
|
|
buf+=" \"elapsed\": \"$(fmt_elapsed "$ELAPSED")\","$'\n'
|
|
buf+=" \"elapsed_seconds\": ${ELAPSED}"$'\n'
|
|
buf+=' }'$'\n'
|
|
buf+='}'
|
|
|
|
printf '%s\n' "$buf"
|
|
}
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Format: text
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
format_text() {
|
|
local buf="" i
|
|
for (( i = 1; i <= TOTAL; i++ )); do
|
|
local cmd="${CONSTRUCTED[$((i-1))]}"
|
|
local ec="${EXIT_CODES[$i]:-255}"
|
|
local sig="${SIGNALS[$i]:-0}"
|
|
|
|
# Logs
|
|
if [[ "$SHOW_LOGS" == true ]]; then
|
|
local logs
|
|
logs=$(get_logs "$i")
|
|
[[ -n "$logs" ]] && buf+="${logs}"$'\n'
|
|
fi
|
|
|
|
# Exit code | command
|
|
if [[ "$SHOW_EXIT_CODE" == true ]]; then
|
|
if (( sig != 0 )); then
|
|
buf+="${YLW}exit code: TIMEOUT${RST} | ${cmd}"$'\n'
|
|
elif (( ec == 0 )); then
|
|
buf+="${GRN}exit code: ${ec}${RST} | ${cmd}"$'\n'
|
|
else
|
|
buf+="${RED}exit code: ${ec}${RST} | ${cmd}"$'\n'
|
|
fi
|
|
else
|
|
buf+="${cmd}"$'\n'
|
|
fi
|
|
|
|
buf+=$'\n'
|
|
done
|
|
|
|
# Summary
|
|
buf+="${BLD}$(printf '─%.0s' $(seq 1 50))${RST}"$'\n'
|
|
|
|
buf+="${BLD}Total:${RST} ${TOTAL} "
|
|
buf+="${GRN}Passed:${RST} ${SUCCEEDED} "
|
|
buf+="${RED}Failed:${RST} ${FAILED}"
|
|
(( TIMED_OUT > 0 )) && buf+=" ${YLW}Timeout:${RST} ${TIMED_OUT}"
|
|
buf+=" ${DIM}($(fmt_elapsed "$ELAPSED"))${RST}"$'\n'
|
|
|
|
printf '%s\n' "$buf"
|
|
}
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
# Emit output
|
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
RESULT=""
|
|
if [[ "$FORMAT" == "json" ]]; then
|
|
RESULT=$(format_json)
|
|
else
|
|
RESULT=$(format_text)
|
|
fi
|
|
|
|
if [[ "$SHORT_OUTPUT" == true ]]; then
|
|
# Determine output file path
|
|
if [[ -z "$OUTPUT_FILE" ]]; then
|
|
local_ext="json"
|
|
[[ "$FORMAT" != "json" ]] && local_ext="txt"
|
|
OUTPUT_FILE="${PWD}/parallel-results.$(date +%Y%m%d_%H%M%S).${local_ext}"
|
|
fi
|
|
|
|
# Strip ANSI when writing to file
|
|
printf '%s\n' "$RESULT" | strip_ansi > "$OUTPUT_FILE"
|
|
|
|
printf '%s\n' "${GRN}✓${RST} Results saved to ${BLD}${OUTPUT_FILE}${RST}" >&2
|
|
|
|
# Short summary on stderr
|
|
printf '%s' "${BLD}Total:${RST} ${TOTAL} ${GRN}OK:${RST} ${SUCCEEDED} ${RED}Fail:${RST} ${FAILED}" >&2
|
|
(( TIMED_OUT > 0 )) && printf ' %s' "${YLW}Timeout:${RST} ${TIMED_OUT}" >&2
|
|
printf ' %s\n' "${DIM}($(fmt_elapsed "$ELAPSED"))${RST}" >&2
|
|
else
|
|
printf '%s\n' "$RESULT"
|
|
fi
|
|
|
|
# ── Footer with progress bar ─────────────────────────────────────────────────
|
|
printf '\n' >&2
|
|
|
|
if (( TOTAL > 0 )); then
|
|
pct=$(( SUCCEEDED * 100 / TOTAL ))
|
|
printf ' %s ' "$(progress_bar "$pct" 30)" >&2
|
|
printf '%s\n' "${DIM}${SUCCEEDED}/${TOTAL} succeeded${RST}" >&2
|
|
fi
|
|
|
|
printf '%s\n\n' \
|
|
"${BLD}${CYN}■ Done${RST} — ${TOTAL} commands in $(fmt_elapsed "$ELAPSED")" >&2
|
|
|
|
# Exit non-zero if any command failed
|
|
(( FAILED > 0 )) && exit 1
|
|
exit 0
|