#!/usr/bin/env bash ######################################################################################### #### hetzner-fleet-manager.sh — Inventory, health checks, and bulk operations for #### #### Hetzner Cloud servers via the REST API. Fleet-wide visibility and control #### #### Requires: bash 4+, curl, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### ./hetzner-fleet-manager.sh --inventory --all #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Colors (pre-initialized) ───────────────────────────────────────── RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET="" setup_colors() { if [[ "${COLOR:-auto}" == "never" ]]; then return fi if [[ "${COLOR:-auto}" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' 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; } section_header() { echo "" echo -e " ${BOLD}${CYAN}── $1 ──${RESET}" echo "" } field() { printf " ${BOLD}%-22s${RESET} %s\n" "$1" "$2" } field_color() { printf " ${BOLD}%-22s${RESET} %b\n" "$1" "$2" } elapsed() { local end_time end_time=$(date +%s) echo "$(( end_time - START_TIME ))s" } # ── Defaults ────────────────────────────────────────────────────────── RUN_MODE="" SERVER_ID="" TARGET_ALL="false" LABEL_SELECTOR="" LABEL_SUB_MODE="" OUTPUT_FORMAT="${HFM_FORMAT:-table}" PING_CHECK="false" FORCE="false" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── Credentials ─────────────────────────────────────────────────────── HCLOUD_TOKEN="${HCLOUD_TOKEN:-}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME START_TIME="" ACTION_OK=0 ACTION_FAIL=0 # ── API helpers ────────────────────────────────────────────────────── hcloud_api() { local method="$1" endpoint="$2" shift 2 local attempt=0 max_attempts=3 while (( attempt < max_attempts )); do local http_code http_code=$(curl -s -o /tmp/hfm_resp.json -w "%{http_code}" \ -X "$method" \ -H "Authorization: Bearer ${HCLOUD_TOKEN}" \ -H "Content-Type: application/json" \ "https://api.hetzner.cloud/v1${endpoint}" "$@") verbose "API ${method} ${endpoint} → HTTP ${http_code}" if [[ "$http_code" == "429" ]]; then ((attempt++)) || true local wait=$(( attempt * 5 )) warn "Rate limited — retrying in ${wait}s (attempt ${attempt}/${max_attempts})" sleep "$wait" continue fi if [[ "$http_code" =~ ^[45] ]]; then local errmsg errmsg=$(jq -r '.error.message // empty' /tmp/hfm_resp.json 2>/dev/null) [[ -n "$errmsg" ]] && verbose "API error: ${errmsg}" fi cat /tmp/hfm_resp.json return 0 done err "API request failed after ${max_attempts} attempts: ${method} ${endpoint}" return 1 } check_credentials() { [[ -z "$HCLOUD_TOKEN" ]] && die "HCLOUD_TOKEN not set" } check_deps() { command -v curl &>/dev/null || die "curl is required" command -v jq &>/dev/null || die "jq is required" } # ── Server helpers ─────────────────────────────────────────────────── get_all_server_ids() { local page=1 per_page=50 ids="" while true; do local resp resp=$(hcloud_api GET "/servers?page=${page}&per_page=${per_page}") local page_ids page_ids=$(echo "$resp" | jq -r '.servers[].id' 2>/dev/null) [[ -z "$page_ids" ]] && break ids="${ids}${ids:+$'\n'}${page_ids}" local count count=$(echo "$page_ids" | wc -l) (( count < per_page )) && break ((page++)) || true done echo "$ids" } get_server_ids_by_label() { local selector="$1" local page=1 per_page=50 ids="" while true; do local resp resp=$(hcloud_api GET "/servers?page=${page}&per_page=${per_page}&label_selector=$(urlencode "$selector")") local page_ids page_ids=$(echo "$resp" | jq -r '.servers[].id' 2>/dev/null) [[ -z "$page_ids" ]] && break ids="${ids}${ids:+$'\n'}${page_ids}" local count count=$(echo "$page_ids" | wc -l) (( count < per_page )) && break ((page++)) || true done echo "$ids" } urlencode() { local string="$1" python3 -c "import urllib.parse; print(urllib.parse.quote('$string', safe=''))" 2>/dev/null \ || echo "$string" } get_server_name() { local sid="$1" hcloud_api GET "/servers/${sid}" \ | jq -r '.server.name // "unknown"' 2>/dev/null } get_server_ids() { if [[ "$TARGET_ALL" == "true" ]]; then get_all_server_ids elif [[ -n "$SERVER_ID" ]]; then echo "$SERVER_ID" elif [[ -n "$LABEL_SELECTOR" ]]; then get_server_ids_by_label "$LABEL_SELECTOR" else die "Specify --server ID, --all, or --label-selector KEY=VALUE" fi } # ══════════════════════════════════════════════════════════════════════ # INVENTORY # ══════════════════════════════════════════════════════════════════════ do_inventory() { local page=1 per_page=50 all_data="[]" local query="/servers?page=${page}&per_page=${per_page}" [[ -n "$LABEL_SELECTOR" ]] && query="${query}&label_selector=$(urlencode "$LABEL_SELECTOR")" while true; do local resp resp=$(hcloud_api GET "$query") local page_data page_data=$(echo "$resp" | jq '.servers // []' 2>/dev/null) local page_count page_count=$(echo "$page_data" | jq 'length' 2>/dev/null || echo 0) [[ "$page_count" -eq 0 ]] && break all_data=$(echo -e "${all_data}\n${page_data}" | jq -s 'add' 2>/dev/null) (( page_count < per_page )) && break ((page++)) || true query="/servers?page=${page}&per_page=${per_page}" [[ -n "$LABEL_SELECTOR" ]] && query="${query}&label_selector=$(urlencode "$LABEL_SELECTOR")" done local total total=$(echo "$all_data" | jq 'length' 2>/dev/null || echo 0) [[ "$total" -eq 0 ]] && die "No servers found" case "$OUTPUT_FORMAT" in json) echo "$all_data" | jq '.' ;; ansible) echo "[hetzner]" echo "$all_data" | jq -r \ '.[] | (.public_net.ipv4.ip // "unknown") + " # " + (.name // "unknown") + " id=" + (.id | tostring)' \ 2>/dev/null ;; *) section_header "Fleet Inventory" printf " ${BOLD}%-10s %-20s %-13s %-16s %-10s %-10s${RESET}\n" \ "ID" "NAME" "STATUS" "IP" "LOCATION" "TYPE" printf " %s\n" "$(printf '%.0s─' {1..81})" echo "$all_data" | jq -r \ '.[] | "\(.id)\t\(.name // "unknown")\t\(.status // "unknown")\t\(.public_net.ipv4.ip // "—")\t\(.datacenter.location.name // "—")\t\(.server_type.name // "—")"' \ 2>/dev/null \ | while IFS=$'\t' read -r sid name status ip location stype; do printf " %-10s %-20s %-13s %-16s %-10s %-10s\n" \ "$sid" "${name:0:18}" "$status" "$ip" "${location:0:8}" "$stype" done echo "" field "Total:" "$total" ;; esac } # ══════════════════════════════════════════════════════════════════════ # HEALTH # ══════════════════════════════════════════════════════════════════════ do_health() { local ids ids=$(get_server_ids) [[ -z "$ids" ]] && die "No servers found" local running=0 stopped=0 errored=0 total_servers=0 local results="" while IFS= read -r sid; do [[ -z "$sid" ]] && continue ((total_servers++)) || true local resp resp=$(hcloud_api GET "/servers/${sid}") local name status ip name=$(echo "$resp" | jq -r '.server.name // "unknown"' 2>/dev/null) status=$(echo "$resp" | jq -r '.server.status // "unknown"' 2>/dev/null) ip=$(echo "$resp" | jq -r '.server.public_net.ipv4.ip // ""' 2>/dev/null) local ping_result="—" if [[ "$PING_CHECK" == "true" && -n "$ip" ]]; then if ping -c 1 -W 3 "$ip" &>/dev/null; then ping_result="reachable" else ping_result="unreachable" fi fi case "$status" in running) ((running++)) || true ;; off) ((stopped++)) || true ;; *) ((errored++)) || true ;; esac results="${results}${sid}\t${name}\t${status}\t${ip}\t${ping_result}\n" done <<< "$ids" if [[ "$OUTPUT_FORMAT" == "prometheus" ]]; then cat < /dev/null 2>&1; then echo -e " ${GREEN}✓${RESET} ${sname} (${sid}) ${action} sent" ((ACTION_OK++)) || true else echo -e " ${RED}✗${RESET} ${sname} (${sid}) ${action} failed" ((ACTION_FAIL++)) || true fi sleep 1 done <<< "$ids" echo "" field_color "Succeeded:" "${GREEN}${ACTION_OK}${RESET}" if [[ "$ACTION_FAIL" -gt 0 ]]; then field_color "Failed:" "${RED}${ACTION_FAIL}${RESET}" fi } # ══════════════════════════════════════════════════════════════════════ # LABELS # ══════════════════════════════════════════════════════════════════════ do_labels() { if [[ "$LABEL_SUB_MODE" == "list" ]]; then local page=1 per_page=50 all_data="[]" while true; do local resp resp=$(hcloud_api GET "/servers?page=${page}&per_page=${per_page}") local page_data page_data=$(echo "$resp" | jq '.servers // []' 2>/dev/null) local page_count page_count=$(echo "$page_data" | jq 'length' 2>/dev/null || echo 0) [[ "$page_count" -eq 0 ]] && break all_data=$(echo -e "${all_data}\n${page_data}" | jq -s 'add' 2>/dev/null) (( page_count < per_page )) && break ((page++)) || true done local labels_json labels_json=$(echo "$all_data" | jq '[.[].labels // {} | to_entries[]] | group_by(.key) | map({key: .[0].key, values: (map(.value) | unique)})' 2>/dev/null) if [[ "$OUTPUT_FORMAT" == "json" ]]; then echo "$labels_json" | jq '.' return fi section_header "Labels" printf " ${BOLD}%-25s %-50s${RESET}\n" "KEY" "VALUES" printf " %s\n" "$(printf '%.0s─' {1..77})" echo "$labels_json" | jq -r '.[] | "\(.key)\t\(.values | join(", "))"' 2>/dev/null \ | while IFS=$'\t' read -r lkey lvals; do printf " %-25s %-50s\n" "${lkey:0:23}" "${lvals:0:48}" done elif [[ "$LABEL_SUB_MODE" == "filter" ]]; then [[ -z "$LABEL_SELECTOR" ]] && die "Specify --label-selector KEY=VALUE with --filter" do_inventory else die "Specify --list or --filter with --labels" fi } # ══════════════════════════════════════════════════════════════════════ # HELP # ══════════════════════════════════════════════════════════════════════ show_help() { cat <