#!/usr/bin/env bash ######################################################################################### #### salt-roster-generator.sh — Generate Salt SSH roster files from external sources #### #### Supports CSV files, Ansible inventory (INI format), and AWS EC2 instances #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### ./salt-roster-generator.sh --csv hosts.csv -o roster #### #### ./salt-roster-generator.sh --ansible-inventory /etc/ansible/hosts -o roster #### #### ./salt-roster-generator.sh --ec2 --region us-east-1 -o roster #### #### ./salt-roster-generator.sh --ec2 --tag "Environment=production" -o roster #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── VERSION="1.0" SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME INPUT_MODE="" CSV_FILE="" ANSIBLE_FILE="" EC2_MODE=false EC2_REGION="" EC2_RUNNING_ONLY=true declare -a EC2_TAGS=() OUTPUT_FILE="" DEFAULT_USER="root" DEFAULT_PORT="22" ENABLE_SUDO=false SSH_PRIV="" # ── Logging ─────────────────────────────────────────────────────────── err() { echo "[ERROR] $*" >&2; } die() { err "$@"; exit 1; } # ══════════════════════════════════════════════════════════════════════ # USAGE # ══════════════════════════════════════════════════════════════════════ usage() { cat </dev/null; then die "aws cli not found. Install it to use --ec2 mode." fi ;; esac } # ══════════════════════════════════════════════════════════════════════ # ROSTER GENERATION # ══════════════════════════════════════════════════════════════════════ generate_header() { local source_label="$1" echo "# Salt SSH Roster" echo "# Generated by ${SCRIPT_NAME} v${VERSION}" echo "# Source: ${source_label}" echo "# Date: $(date '+%Y-%m-%d')" echo "" } generate_entry() { local name="$1" local host="$2" local user="${3:-$DEFAULT_USER}" local port="${4:-$DEFAULT_PORT}" local sudo="${5:-}" local priv="${6:-$SSH_PRIV}" [[ -z "$name" || -z "$host" ]] && return echo "${name}:" echo " host: ${host}" echo " user: ${user}" echo " port: ${port}" if [[ "$ENABLE_SUDO" == true || "$sudo" == "true" || "$sudo" == "True" ]]; then echo " sudo: True" fi if [[ -n "$priv" ]]; then echo " priv: ${priv}" fi echo "" } # ── CSV Parser ──────────────────────────────────────────────────────── parse_csv() { local file="$1" local header_line header_line=$(head -1 "$file") # Map column positions from header local -A col_map local idx=0 IFS=',' read -ra cols <<< "$header_line" for c in "${cols[@]}"; do c=$(echo "$c" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]') col_map["$c"]=$idx ((idx++)) || true done # Require name and host columns [[ -z "${col_map[name]:-}" ]] && die "CSV missing required column: name" [[ -z "${col_map[host]:-}" ]] && die "CSV missing required column: host" local name_idx="${col_map[name]}" local host_idx="${col_map[host]}" local user_idx="${col_map[user]:-}" local port_idx="${col_map[port]:-}" local sudo_idx="${col_map[sudo]:-}" local priv_idx="${col_map[priv]:-}" generate_header "$file" local line_num=0 while IFS= read -r line; do ((line_num++)) || true [[ $line_num -eq 1 ]] && continue # skip header [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue IFS=',' read -ra fields <<< "$line" local name="${fields[$name_idx]:-}" local host="${fields[$host_idx]:-}" local user="${DEFAULT_USER}" local port="${DEFAULT_PORT}" local sudo="" local priv="" [[ -n "$user_idx" && -n "${fields[$user_idx]:-}" ]] && user="${fields[$user_idx]}" [[ -n "$port_idx" && -n "${fields[$port_idx]:-}" ]] && port="${fields[$port_idx]}" [[ -n "$sudo_idx" && -n "${fields[$sudo_idx]:-}" ]] && sudo="${fields[$sudo_idx]}" [[ -n "$priv_idx" && -n "${fields[$priv_idx]:-}" ]] && priv="${fields[$priv_idx]}" # Trim whitespace name=$(echo "$name" | xargs) host=$(echo "$host" | xargs) user=$(echo "$user" | xargs) port=$(echo "$port" | xargs) generate_entry "$name" "$host" "$user" "$port" "$sudo" "$priv" done < "$file" } # ── Ansible INI Inventory Parser ────────────────────────────────────── parse_ansible_inventory() { local file="$1" generate_header "$file" while IFS= read -r line; do # Strip leading/trailing whitespace line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') # Skip empty lines, comments, group headers, group:vars, group:children [[ -z "$line" ]] && continue [[ "$line" =~ ^# ]] && continue [[ "$line" =~ ^\[.*\] ]] && continue # Parse host line: hostname key=value key=value ... local hostname hostname=$(echo "$line" | awk '{print $1}') local host="$hostname" local user="$DEFAULT_USER" local port="$DEFAULT_PORT" local priv="" # Extract ansible variables if echo "$line" | grep -q 'ansible_host='; then host=$(echo "$line" | grep -oP 'ansible_host=\K[^\s]+') fi if echo "$line" | grep -q 'ansible_user='; then user=$(echo "$line" | grep -oP 'ansible_user=\K[^\s]+') fi if echo "$line" | grep -q 'ansible_port='; then port=$(echo "$line" | grep -oP 'ansible_port=\K[^\s]+') fi if echo "$line" | grep -q 'ansible_ssh_private_key_file='; then priv=$(echo "$line" | grep -oP 'ansible_ssh_private_key_file=\K[^\s]+') fi generate_entry "$hostname" "$host" "$user" "$port" "" "$priv" done < "$file" } # ── EC2 Parser ──────────────────────────────────────────────────────── parse_ec2() { local -a aws_args=("ec2" "describe-instances") # Region if [[ -n "$EC2_REGION" ]]; then aws_args+=("--region" "$EC2_REGION") fi # Build filters local -a filters=() if [[ "$EC2_RUNNING_ONLY" == true ]]; then filters+=("Name=instance-state-name,Values=running") fi for tag in "${EC2_TAGS[@]}"; do local key="${tag%%=*}" local val="${tag#*=}" filters+=("Name=tag:${key},Values=${val}") done if [[ ${#filters[@]} -gt 0 ]]; then aws_args+=("--filters" "${filters[@]}") fi aws_args+=( "--query" "Reservations[].Instances[].[Tags[?Key==\`Name\`].Value | [0], PrivateIpAddress, InstanceId]" "--output" "text" ) local ec2_output ec2_output=$(aws "${aws_args[@]}" 2>/dev/null) || die "aws ec2 describe-instances failed. Check credentials and region." [[ -z "$ec2_output" ]] && die "No EC2 instances found matching the specified filters." local source_label="ec2" [[ -n "$EC2_REGION" ]] && source_label="ec2 (${EC2_REGION})" generate_header "$source_label" while IFS=$'\t' read -r name ip instance_id; do # Use instance ID if no Name tag if [[ -z "$name" || "$name" == "None" ]]; then name="$instance_id" fi [[ -z "$ip" || "$ip" == "None" ]] && continue generate_entry "$name" "$ip" "$DEFAULT_USER" "$DEFAULT_PORT" "" "" done <<< "$ec2_output" } # ══════════════════════════════════════════════════════════════════════ # OUTPUT # ══════════════════════════════════════════════════════════════════════ write_output() { local content="$1" if [[ -n "$OUTPUT_FILE" ]]; then echo "$content" > "$OUTPUT_FILE" echo "Roster written to ${OUTPUT_FILE} ($(echo "$content" | grep -c ':$' || true) hosts)" >&2 else echo "$content" fi } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" validate local roster="" case "$INPUT_MODE" in csv) roster=$(parse_csv "$CSV_FILE") ;; ansible) roster=$(parse_ansible_inventory "$ANSIBLE_FILE") ;; ec2) roster=$(parse_ec2) ;; esac write_output "$roster" } main "$@"