#!/usr/bin/env bash ######################################################################################### #### hetzner-cost-monitor.sh — Track and report Hetzner Cloud spending via the REST #### #### API. Server, volume, snapshot, and load balancer costs with alert thresholds #### #### Requires: bash 4+, curl, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### ./hetzner-cost-monitor.sh --summary #### #### #### #### 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="" OUTPUT_FORMAT="${HCM_FORMAT:-table}" ALERT_THRESHOLD="${HCM_ALERT:-0}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── Credentials ─────────────────────────────────────────────────────── HCLOUD_TOKEN="${HCLOUD_TOKEN:-}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME START_TIME="" # ── 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/hcm_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/hcm_resp.json 2>/dev/null) [[ -n "$errmsg" ]] && verbose "API error: ${errmsg}" fi cat /tmp/hcm_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" } # ── Pagination helper ──────────────────────────────────────────────── fetch_all() { local endpoint="$1" key="$2" local page=1 per_page=50 all_data="[]" while true; do local sep="?" [[ "$endpoint" == *"?"* ]] && sep="&" local resp resp=$(hcloud_api GET "${endpoint}${sep}page=${page}&per_page=${per_page}") local page_data page_data=$(echo "$resp" | jq ".${key} // []" 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 echo "$all_data" } # ── Cost calculation helpers ───────────────────────────────────────── get_pricing() { hcloud_api GET "/pricing" } calc_server_costs() { local servers="$1" pricing="$2" local total="0" echo "$servers" | jq -r '.[] | "\(.server_type.name)\t\(.status)"' 2>/dev/null \ | while IFS=$'\t' read -r stype status; do if [[ "$status" == "running" ]]; then local hourly hourly=$(echo "$pricing" | jq -r \ --arg t "$stype" \ '.pricing.server_types[] | select(.name == $t) | .prices[0].price_hourly.gross' \ 2>/dev/null) [[ -n "$hourly" && "$hourly" != "null" ]] && echo "$hourly" fi done \ | awk '{s+=$1} END {printf "%.2f", s * 730}' } # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ do_summary() { local pricing pricing=$(get_pricing) local servers servers=$(fetch_all "/servers" "servers") local server_count server_count=$(echo "$servers" | jq 'length' 2>/dev/null || echo 0) local running_count running_count=$(echo "$servers" | jq '[.[] | select(.status == "running")] | length' 2>/dev/null || echo 0) local volumes volumes=$(fetch_all "/volumes" "volumes") local volume_count volume_count=$(echo "$volumes" | jq 'length' 2>/dev/null || echo 0) local volume_gb volume_gb=$(echo "$volumes" | jq '[.[].size] | add // 0' 2>/dev/null || echo 0) local images images=$(fetch_all "/images?type=snapshot" "images") local snapshot_count snapshot_count=$(echo "$images" | jq 'length' 2>/dev/null || echo 0) local snapshot_gb snapshot_gb=$(echo "$images" | jq '[.[].image_size // 0] | add // 0' 2>/dev/null || echo 0) local lbs lbs=$(fetch_all "/load_balancers" "load_balancers") local lb_count lb_count=$(echo "$lbs" | jq 'length' 2>/dev/null || echo 0) local ips ips=$(fetch_all "/floating_ips" "floating_ips") local ip_count ip_count=$(echo "$ips" | jq 'length' 2>/dev/null || echo 0) # Monthly cost estimates from pricing API local volume_price_gb volume_price_gb=$(echo "$pricing" | jq -r '.pricing.volume.price_per_gb_per_month.gross // "0"' 2>/dev/null) local volume_cost volume_cost=$(awk "BEGIN {printf \"%.2f\", ${volume_gb} * ${volume_price_gb}}") local snapshot_price_gb snapshot_price_gb=$(echo "$pricing" | jq -r '.pricing.image.price_per_gb_month.gross // "0"' 2>/dev/null) local snapshot_cost snapshot_cost=$(awk "BEGIN {printf \"%.2f\", ${snapshot_gb} * ${snapshot_price_gb}}") local ip_price ip_price=$(echo "$pricing" | jq -r '.pricing.floating_ip.price_monthly.gross // "0"' 2>/dev/null) local ip_cost ip_cost=$(awk "BEGIN {printf \"%.2f\", ${ip_count} * ${ip_price}}") # Server costs — sum hourly × 730 hours/month local server_cost="0.00" while IFS=$'\t' read -r stype status; do [[ -z "$stype" ]] && continue if [[ "$status" == "running" ]]; then local hourly hourly=$(echo "$pricing" | jq -r \ --arg t "$stype" \ '[.pricing.server_types[] | select(.name == $t) | .prices[0].price_hourly.gross][0] // "0"' \ 2>/dev/null) server_cost=$(awk "BEGIN {printf \"%.2f\", ${server_cost} + (${hourly} * 730)}") fi done < <(echo "$servers" | jq -r '.[] | "\(.server_type.name)\t\(.status)"' 2>/dev/null) # LB costs local lb_cost="0.00" while IFS= read -r lbtype; do [[ -z "$lbtype" ]] && continue local lb_hourly lb_hourly=$(echo "$pricing" | jq -r \ --arg t "$lbtype" \ '[.pricing.load_balancer_types[] | select(.name == $t) | .prices[0].price_hourly.gross][0] // "0"' \ 2>/dev/null) lb_cost=$(awk "BEGIN {printf \"%.2f\", ${lb_cost} + (${lb_hourly} * 730)}") done < <(echo "$lbs" | jq -r '.[].load_balancer_type.name // empty' 2>/dev/null) local total_cost total_cost=$(awk "BEGIN {printf \"%.2f\", ${server_cost} + ${volume_cost} + ${snapshot_cost} + ${lb_cost} + ${ip_cost}}") # Alert check local alert_triggered="false" if [[ "$ALERT_THRESHOLD" != "0" ]]; then local over over=$(awk "BEGIN {print (${total_cost} > ${ALERT_THRESHOLD}) ? 1 : 0}") [[ "$over" == "1" ]] && alert_triggered="true" fi case "$OUTPUT_FORMAT" in json) jq -n \ --argjson servers "$server_count" \ --argjson running "$running_count" \ --argjson volumes "$volume_count" \ --argjson volume_gb "$volume_gb" \ --argjson snapshots "$snapshot_count" \ --arg snapshot_gb "$snapshot_gb" \ --argjson load_balancers "$lb_count" \ --argjson floating_ips "$ip_count" \ --arg server_cost "$server_cost" \ --arg volume_cost "$volume_cost" \ --arg snapshot_cost "$snapshot_cost" \ --arg lb_cost "$lb_cost" \ --arg ip_cost "$ip_cost" \ --arg total_cost "$total_cost" \ --arg alert_threshold "$ALERT_THRESHOLD" \ --argjson alert_triggered "$alert_triggered" \ '{ servers: $servers, running: $running, volumes: $volumes, volume_gb: $volume_gb, snapshots: $snapshots, snapshot_gb: ($snapshot_gb | tonumber), load_balancers: $load_balancers, floating_ips: $floating_ips, monthly_estimate: { servers: $server_cost, volumes: $volume_cost, snapshots: $snapshot_cost, load_balancers: $lb_cost, floating_ips: $ip_cost, total: $total_cost }, alert: { threshold: $alert_threshold, triggered: $alert_triggered } }' ;; prometheus) cat </dev/null || echo 0) [[ "$server_count" -eq 0 ]] && die "No servers found" case "$OUTPUT_FORMAT" in json) echo "$servers" | jq '[.[] | { id: .id, name: .name, status: .status, type: .server_type.name, location: .datacenter.location.name, ip: .public_net.ipv4.ip }]' ;; *) section_header "Server Cost Breakdown" printf " ${BOLD}%-10s %-20s %-10s %-8s %-8s %10s${RESET}\n" \ "ID" "NAME" "TYPE" "STATUS" "LOC" "MONTHLY €" printf " %s\n" "$(printf '%.0s─' {1..70})" while IFS=$'\t' read -r sid sname stype status loc; do [[ -z "$sid" ]] && continue local hourly="0" if [[ "$status" == "running" ]]; then hourly=$(echo "$pricing" | jq -r \ --arg t "$stype" \ '[.pricing.server_types[] | select(.name == $t) | .prices[0].price_hourly.gross][0] // "0"' \ 2>/dev/null) fi local monthly monthly=$(awk "BEGIN {printf \"%.2f\", ${hourly} * 730}") local status_color="$GREEN" [[ "$status" != "running" ]] && status_color="$DIM" printf " %-10s %-20s %-10s " "$sid" "${sname:0:18}" "$stype" echo -ne "${status_color}" printf "%-8s" "$status" echo -ne "${RESET}" printf " %-8s %10s\n" "${loc:0:6}" "$monthly" done < <(echo "$servers" | jq -r \ '.[] | "\(.id)\t\(.name // "unknown")\t\(.server_type.name // "—")\t\(.status)\t\(.datacenter.location.name // "—")"' \ 2>/dev/null) echo "" field "Servers:" "$server_count" ;; esac } # ══════════════════════════════════════════════════════════════════════ # RESOURCES # ══════════════════════════════════════════════════════════════════════ do_resources() { case "$OUTPUT_FORMAT" in json) local volumes snapshots lbs ips volumes=$(fetch_all "/volumes" "volumes") snapshots=$(fetch_all "/images?type=snapshot" "images") lbs=$(fetch_all "/load_balancers" "load_balancers") ips=$(fetch_all "/floating_ips" "floating_ips") jq -n \ --argjson volumes "$volumes" \ --argjson snapshots "$snapshots" \ --argjson load_balancers "$lbs" \ --argjson floating_ips "$ips" \ '{volumes: $volumes, snapshots: $snapshots, load_balancers: $load_balancers, floating_ips: $floating_ips}' ;; *) # Volumes local volumes volumes=$(fetch_all "/volumes" "volumes") local vol_count vol_count=$(echo "$volumes" | jq 'length' 2>/dev/null || echo 0) if [[ "$vol_count" -gt 0 ]]; then section_header "Volumes" printf " ${BOLD}%-12s %-20s %-8s %-10s %-8s${RESET}\n" \ "ID" "NAME" "SIZE" "LOCATION" "STATUS" printf " %s\n" "$(printf '%.0s─' {1..60})" echo "$volumes" | jq -r \ '.[] | "\(.id)\t\(.name // "—")\t\(.size)GB\t\(.location.name // "—")\t\(.status // "—")"' \ 2>/dev/null \ | while IFS=$'\t' read -r vid vname vsize vloc vstatus; do printf " %-12s %-20s %-8s %-10s %-8s\n" \ "$vid" "${vname:0:18}" "$vsize" "${vloc:0:8}" "$vstatus" done echo "" field "Volumes:" "$vol_count" fi # Snapshots local snapshots snapshots=$(fetch_all "/images?type=snapshot" "images") local snap_count snap_count=$(echo "$snapshots" | jq 'length' 2>/dev/null || echo 0) if [[ "$snap_count" -gt 0 ]]; then section_header "Snapshots" printf " ${BOLD}%-12s %-22s %-10s %-20s${RESET}\n" \ "ID" "DESCRIPTION" "SIZE" "CREATED" printf " %s\n" "$(printf '%.0s─' {1..66})" echo "$snapshots" | jq -r \ '.[] | "\(.id)\t\(.description // "—")\t\(.image_size // 0)GB\t\(.created // "—")"' \ 2>/dev/null \ | while IFS=$'\t' read -r iid idesc isize icreated; do printf " %-12s %-22s %-10s %-20s\n" \ "$iid" "${idesc:0:20}" "$isize" "${icreated:0:19}" done echo "" field "Snapshots:" "$snap_count" fi # Floating IPs local ips ips=$(fetch_all "/floating_ips" "floating_ips") local ip_count ip_count=$(echo "$ips" | jq 'length' 2>/dev/null || echo 0) if [[ "$ip_count" -gt 0 ]]; then section_header "Floating IPs" printf " ${BOLD}%-12s %-18s %-10s %-12s${RESET}\n" \ "ID" "IP" "TYPE" "LOCATION" printf " %s\n" "$(printf '%.0s─' {1..54})" echo "$ips" | jq -r \ '.[] | "\(.id)\t\(.ip)\t\(.type)\t\(.home_location.name // "—")"' \ 2>/dev/null \ | while IFS=$'\t' read -r fid fip ftype floc; do printf " %-12s %-18s %-10s %-12s\n" \ "$fid" "$fip" "$ftype" "$floc" done echo "" field "Floating IPs:" "$ip_count" fi ;; esac } # ══════════════════════════════════════════════════════════════════════ # HELP # ══════════════════════════════════════════════════════════════════════ show_help() { cat < /var/lib/node_exporter/textfile/hetzner_cost.prom 2>/dev/null ${BOLD}EXIT CODES${RESET} 0 Success 1 Runtime error EOF } # ══════════════════════════════════════════════════════════════════════ # PARSE ARGS # ══════════════════════════════════════════════════════════════════════ parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --summary) RUN_MODE="summary"; shift ;; --breakdown) RUN_MODE="breakdown"; shift ;; --resources) RUN_MODE="resources"; shift ;; --format) OUTPUT_FORMAT="${2:?--format requires a value}"; shift 2 ;; --alert) ALERT_THRESHOLD="${2:?--alert requires a threshold}"; shift 2 ;; --verbose) VERBOSE="true"; shift ;; --no-color) COLOR="never"; shift ;; --help|-h) setup_colors; show_help; exit 0 ;; *) die "Unknown option: $1 (see --help)" ;; esac done } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors if [[ -z "$RUN_MODE" ]]; then RUN_MODE="summary" fi check_deps check_credentials START_TIME=$(date +%s) case "$RUN_MODE" in summary) do_summary ;; breakdown) do_breakdown ;; resources) do_resources ;; *) die "Unknown mode: ${RUN_MODE}" ;; esac if [[ "$OUTPUT_FORMAT" != "prometheus" ]]; then echo "" field "Duration:" "$(elapsed)" fi } main "$@"