#!/usr/bin/env bash ######################################################################################### #### ssm-fleet-runner.sh — Run commands across EC2 instances via AWS Systems Manager #### #### Supports tag-based targeting, output collection, patch scanning, and inventory #### #### Requires: bash 4+, aws-cli v2, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### ./ssm-fleet-runner.sh --run "uptime" --tag Environment=production #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── AWS_REGION="${AWS_REGION:-}" INSTANCE_IDS="${INSTANCE_IDS:-}" SSM_TAG_KEY="${SSM_TAG_KEY:-}" SSM_TAG_VALUE="${SSM_TAG_VALUE:-}" SSM_TIMEOUT="${SSM_TIMEOUT:-600}" SSM_MAX_CONCURRENCY="${SSM_MAX_CONCURRENCY:-50}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" OUTPUT_DIR="${OUTPUT_DIR:-}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME RUN_MODE="" COMMAND_TEXT="" SCRIPT_FILE="" START_TIME="" ALL_INSTANCES="false" # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BLUE="" BOLD="" DIM="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BLUE="" BOLD="" DIM="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; } die() { err "$*"; exit 1; } elapsed() { local end_time end_time=$(date +%s) echo "$(( end_time - START_TIME ))s" } # ── AWS CLI wrapper ─────────────────────────────────────────────────── aws_cmd() { local args=("$@") [[ -n "$AWS_REGION" ]] && args+=(--region "$AWS_REGION") verbose "aws ${args[*]}" aws "${args[@]}" } # ── Dependency check ────────────────────────────────────────────────── check_deps() { for cmd in aws jq; do if ! command -v "$cmd" &>/dev/null; then die "${cmd} is required but not installed" fi done if ! aws sts get-caller-identity &>/dev/null; then die "AWS credentials not configured or expired" fi if [[ -z "$AWS_REGION" ]]; then AWS_REGION=$(aws configure get region 2>/dev/null || echo "") if [[ -z "$AWS_REGION" ]]; then die "AWS_REGION is required (set via env var or aws configure)" fi fi verbose "Using region: ${AWS_REGION}" } # ── Resolve target instances ───────────────────────────────────────── resolve_targets() { local targets=() if [[ -n "$INSTANCE_IDS" ]]; then IFS=',' read -ra targets <<< "$INSTANCE_IDS" echo "${targets[@]}" return fi if [[ -n "$SSM_TAG_KEY" ]]; then aws_cmd ssm describe-instance-information \ --filters "Key=tag:${SSM_TAG_KEY},Values=${SSM_TAG_VALUE:-*}" \ --query 'InstanceInformationList[*].InstanceId' \ --output text 2>/dev/null | tr '\t' ' ' return fi if [[ "$ALL_INSTANCES" == "true" ]]; then aws_cmd ssm describe-instance-information \ --query 'InstanceInformationList[*].InstanceId' \ --output text 2>/dev/null | tr '\t' ' ' return fi die "No targets specified. Use --instance-ids, --tag, or --all-instances" } # ── Get instance name ──────────────────────────────────────────────── get_instance_name() { local instance_id="$1" # shellcheck disable=SC2016 aws_cmd ec2 describe-instances \ --instance-ids "$instance_id" \ --query 'Reservations[0].Instances[0].Tags[?Key==`Name`].Value | [0]' \ --output text 2>/dev/null || echo "N/A" } # ══════════════════════════════════════════════════════════════════════ # RUN COMMAND MODE # ══════════════════════════════════════════════════════════════════════ do_run() { local command_to_run="$COMMAND_TEXT" if [[ "$RUN_MODE" == "script" ]]; then if [[ ! -f "$SCRIPT_FILE" ]]; then die "Script file not found: ${SCRIPT_FILE}" fi command_to_run=$(cat "$SCRIPT_FILE") fi log "Resolving target instances..." local targets_str targets_str=$(resolve_targets) if [[ -z "$targets_str" ]]; then die "No SSM-managed instances found matching criteria" fi local -a target_array read -ra target_array <<< "$targets_str" local count=${#target_array[@]} log "Found ${count} target instance(s)" # Build target specification local target_spec if [[ -n "$INSTANCE_IDS" ]]; then target_spec="--instance-ids ${target_array[*]}" elif [[ -n "$SSM_TAG_KEY" ]]; then target_spec="--targets Key=tag:${SSM_TAG_KEY},Values=${SSM_TAG_VALUE:-*}" else target_spec="--instance-ids ${target_array[*]}" fi log "Sending command to ${count} instance(s)..." verbose "Command: ${command_to_run:0:100}..." local cmd_id # shellcheck disable=SC2086 cmd_id=$(aws_cmd ssm send-command \ --document-name "AWS-RunShellScript" \ --parameters "commands=[\"${command_to_run}\"]" \ $target_spec \ --timeout-seconds "$SSM_TIMEOUT" \ --max-concurrency "${SSM_MAX_CONCURRENCY}" \ --query 'Command.CommandId' \ --output text 2>/dev/null) || die "Failed to send SSM command" log "Command ID: ${cmd_id}" log "Waiting for completion (timeout: ${SSM_TIMEOUT}s)..." # Poll for completion local attempts=0 local max_attempts=$((SSM_TIMEOUT / 5)) local all_done="false" while [[ "$all_done" != "true" && "$attempts" -lt "$max_attempts" ]]; do sleep 5 ((attempts++)) || true local status_json status_json=$(aws_cmd ssm list-command-invocations \ --command-id "$cmd_id" \ --query 'CommandInvocations[*].{Id:InstanceId,Status:Status}' \ --output json 2>/dev/null) || continue local pending pending=$(echo "$status_json" | jq '[.[] | select(.Status == "InProgress" or .Status == "Pending")] | length') if [[ "$pending" -eq 0 ]]; then all_done="true" fi verbose "Poll ${attempts}: ${pending} still in progress" done # Collect results collect_results "$cmd_id" "${target_array[@]}" } collect_results() { local cmd_id="$1" shift local instances=("$@") local success=0 failed=0 timed_out=0 echo "" for instance_id in "${instances[@]}"; do local result_json result_json=$(aws_cmd ssm get-command-invocation \ --command-id "$cmd_id" \ --instance-id "$instance_id" \ --output json 2>/dev/null) || continue local status stdout stderr status=$(echo "$result_json" | jq -r '.Status') stdout=$(echo "$result_json" | jq -r '.StandardOutputContent // ""') stderr=$(echo "$result_json" | jq -r '.StandardErrorContent // ""') local name name=$(get_instance_name "$instance_id") [[ "$name" == "None" ]] && name="N/A" case "$status" in Success) echo -e " ${GREEN}✓${RESET} ${instance_id} (${name})" ((success++)) || true ;; Failed) echo -e " ${RED}✗${RESET} ${instance_id} (${name}) — failed" ((failed++)) || true ;; TimedOut) echo -e " ${YELLOW}⏱${RESET} ${instance_id} (${name}) — timed out" ((timed_out++)) || true ;; *) echo -e " ${DIM}?${RESET} ${instance_id} (${name}) — ${status}" ;; esac if [[ -n "$stdout" && "$OUTPUT_FORMAT" == "text" ]]; then # shellcheck disable=SC2001 echo "$stdout" | sed 's/^/ /' echo "" fi if [[ -n "$stderr" && "$VERBOSE" == "true" ]]; then echo -e " ${RED}stderr:${RESET}" # shellcheck disable=SC2001 echo "$stderr" | sed 's/^/ /' echo "" fi # Save to output directory if [[ -n "$OUTPUT_DIR" ]]; then mkdir -p "$OUTPUT_DIR" echo "$stdout" > "${OUTPUT_DIR}/${instance_id}.stdout.txt" [[ -n "$stderr" ]] && echo "$stderr" > "${OUTPUT_DIR}/${instance_id}.stderr.txt" fi done if [[ "$OUTPUT_FORMAT" == "json" ]]; then aws_cmd ssm list-command-invocations \ --command-id "$cmd_id" \ --details \ --output json 2>/dev/null fi echo "" log "Summary: success=${success}, failed=${failed}, timed_out=${timed_out}" log "Completed in $(elapsed)" } # ══════════════════════════════════════════════════════════════════════ # PATCH SCAN MODE # ══════════════════════════════════════════════════════════════════════ do_patch_scan() { log "Running patch compliance scan..." local targets_str targets_str=$(resolve_targets) if [[ -z "$targets_str" ]]; then die "No SSM-managed instances found matching criteria" fi local -a target_array read -ra target_array <<< "$targets_str" log "Scanning ${#target_array[@]} instance(s) for patch compliance..." local target_spec if [[ -n "$SSM_TAG_KEY" ]]; then target_spec="--targets Key=tag:${SSM_TAG_KEY},Values=${SSM_TAG_VALUE:-*}" else target_spec="--instance-ids ${target_array[*]}" fi local cmd_id # shellcheck disable=SC2086 cmd_id=$(aws_cmd ssm send-command \ --document-name "AWS-RunPatchBaseline" \ --parameters '{"Operation":["Scan"]}' \ $target_spec \ --timeout-seconds "$SSM_TIMEOUT" \ --query 'Command.CommandId' \ --output text 2>/dev/null) || die "Failed to send patch scan command" log "Command ID: ${cmd_id}" log "Waiting for scan completion..." sleep 10 local attempts=0 local max_attempts=$((SSM_TIMEOUT / 10)) while [[ "$attempts" -lt "$max_attempts" ]]; do sleep 10 ((attempts++)) || true local pending # shellcheck disable=SC2016 pending=$(aws_cmd ssm list-command-invocations \ --command-id "$cmd_id" \ --query 'CommandInvocations[?Status==`InProgress` || Status==`Pending`] | length(@)' \ --output text 2>/dev/null) || continue if [[ "$pending" -eq 0 ]]; then break fi verbose "Poll ${attempts}: ${pending} still scanning..." done # Display results echo "" printf " ${BOLD}%-22s %-14s %s${RESET}\n" "INSTANCE" "STATUS" "DETAILS" printf " %s\n" "$(printf '%.0s─' {1..55})" aws_cmd ssm list-command-invocations \ --command-id "$cmd_id" \ --query 'CommandInvocations[*].{Id:InstanceId,Status:Status,Detail:StatusDetails}' \ --output json 2>/dev/null | jq -c '.[]' | while IFS= read -r inv; do local iid status detail iid=$(echo "$inv" | jq -r '.Id') status=$(echo "$inv" | jq -r '.Status') detail=$(echo "$inv" | jq -r '.Detail') local icon="$GREEN✓$RESET" [[ "$status" != "Success" ]] && icon="$RED✗$RESET" printf " %-22s ${icon} %-12s %s\n" "$iid" "$status" "$detail" done echo "" log "Patch scan completed in $(elapsed)" } # ══════════════════════════════════════════════════════════════════════ # PATCH INSTALL MODE # ══════════════════════════════════════════════════════════════════════ do_patch_install() { log "Running patch installation..." warn "This will install patches and may require reboots" local targets_str targets_str=$(resolve_targets) local -a target_array read -ra target_array <<< "$targets_str" log "Installing patches on ${#target_array[@]} instance(s)..." local target_spec if [[ -n "$SSM_TAG_KEY" ]]; then target_spec="--targets Key=tag:${SSM_TAG_KEY},Values=${SSM_TAG_VALUE:-*}" else target_spec="--instance-ids ${target_array[*]}" fi local cmd_id # shellcheck disable=SC2086 cmd_id=$(aws_cmd ssm send-command \ --document-name "AWS-RunPatchBaseline" \ --parameters '{"Operation":["Install"],"RebootOption":["RebootIfNeeded"]}' \ $target_spec \ --timeout-seconds "$SSM_TIMEOUT" \ --query 'Command.CommandId' \ --output text 2>/dev/null) || die "Failed to send patch install command" log "Command ID: ${cmd_id}" log "Patches being installed — monitor with: aws ssm list-command-invocations --command-id ${cmd_id}" log "Completed in $(elapsed)" } # ══════════════════════════════════════════════════════════════════════ # INVENTORY MODE # ══════════════════════════════════════════════════════════════════════ do_inventory() { log "Collecting software inventory..." local targets_str targets_str=$(resolve_targets) local -a target_array read -ra target_array <<< "$targets_str" echo "" printf " ${BOLD}%-22s %-16s %-12s %-14s %s${RESET}\n" "INSTANCE" "PLATFORM" "AGENT_VER" "IP" "NAME" printf " %s\n" "$(printf '%.0s─' {1..80})" for iid in "${target_array[@]}"; do local info_json info_json=$(aws_cmd ssm describe-instance-information \ --filters "Key=InstanceIds,Values=${iid}" \ --query 'InstanceInformationList[0]' \ --output json 2>/dev/null) || continue local platform agent_ver ip_addr name platform=$(echo "$info_json" | jq -r '.PlatformName // "Unknown"') agent_ver=$(echo "$info_json" | jq -r '.AgentVersion // "?"') ip_addr=$(echo "$info_json" | jq -r '.IPAddress // "?"') name=$(get_instance_name "$iid") [[ "$name" == "None" ]] && name="N/A" printf " %-22s %-16s %-12s %-14s %s\n" "$iid" "${platform:0:16}" "${agent_ver:0:12}" "$ip_addr" "${name:0:20}" done echo "" log "Inventory complete — ${#target_array[@]} instance(s)" log "Completed in $(elapsed)" } # ══════════════════════════════════════════════════════════════════════ # HELP # ══════════════════════════════════════════════════════════════════════ show_help() { cat </dev/null || echo 'default')}" echo "Mode: ${RUN_MODE}" echo "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "" check_deps case "$RUN_MODE" in run|script) do_run ;; patch-scan) do_patch_scan ;; patch-install) do_patch_install ;; inventory) do_inventory ;; esac } main "$@"