#!/usr/bin/env bash ######################################################################################### #### contabo-cost-monitor.sh — Track and report Contabo spending via the REST API. #### #### Instance costs, snapshot usage, and alert thresholds with Prometheus output #### #### Requires: bash 4+, curl, jq #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### ./contabo-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="${CCM_FORMAT:-table}" ALERT_THRESHOLD="${CCM_ALERT:-0}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── Credentials ─────────────────────────────────────────────────────── CONTABO_CLIENT_ID="${CONTABO_CLIENT_ID:-}" CONTABO_CLIENT_SECRET="${CONTABO_CLIENT_SECRET:-}" CONTABO_API_USER="${CONTABO_API_USER:-}" CONTABO_API_PASS="${CONTABO_API_PASS:-}" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME START_TIME="" # ── API helpers ────────────────────────────────────────────────────── contabo_token() { local resp resp=$(curl -s -d "client_id=${CONTABO_CLIENT_ID}" \ -d "client_secret=${CONTABO_CLIENT_SECRET}" \ --data-urlencode "username=${CONTABO_API_USER}" \ --data-urlencode "password=${CONTABO_API_PASS}" \ -d "grant_type=password" \ "https://auth.contabo.com/auth/realms/contabo/protocol/openid-connect/token") local token token=$(echo "$resp" | jq -r '.access_token // empty' 2>/dev/null) if [[ -z "$token" ]]; then die "Failed to obtain access token — check credentials" fi echo "$token" } contabo_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/ccm_resp.json -w "%{http_code}" \ -X "$method" \ -H "Authorization: Bearer $(contabo_token)" \ -H "Content-Type: application/json" \ -H "x-request-id: $(cat /proc/sys/kernel/random/uuid 2>/dev/null || date +%s%N)" \ "https://api.contabo.com/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 cat /tmp/ccm_resp.json return 0 done err "API request failed after ${max_attempts} attempts: ${method} ${endpoint}" return 1 } check_credentials() { [[ -z "$CONTABO_CLIENT_ID" ]] && die "CONTABO_CLIENT_ID not set" [[ -z "$CONTABO_CLIENT_SECRET" ]] && die "CONTABO_CLIENT_SECRET not set" [[ -z "$CONTABO_API_USER" ]] && die "CONTABO_API_USER not set" [[ -z "$CONTABO_API_PASS" ]] && die "CONTABO_API_PASS 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_contabo() { local endpoint="$1" key="$2" local page=1 size=100 all_data="[]" while true; do local sep="?" [[ "$endpoint" == *"?"* ]] && sep="&" local resp resp=$(contabo_api GET "${endpoint}${sep}page=${page}&size=${size}") 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 < size )) && break ((page++)) || true done echo "$all_data" } # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ do_summary() { local instances instances=$(fetch_all_contabo "/compute/instances" "data") local instance_count instance_count=$(echo "$instances" | jq 'length' 2>/dev/null || echo 0) local running_count running_count=$(echo "$instances" | jq '[.[] | select(.status == "running")] | length' 2>/dev/null || echo 0) local snapshots snapshots=$(fetch_all_contabo "/compute/snapshots" "data") local snapshot_count snapshot_count=$(echo "$snapshots" | jq 'length' 2>/dev/null || echo 0) local object_storage object_storage=$(fetch_all_contabo "/object-storages" "data") local storage_count storage_count=$(echo "$object_storage" | jq 'length' 2>/dev/null || echo 0) local storage_tb storage_tb=$(echo "$object_storage" | jq '[.[].totalPurchasedSpaceTB // 0] | add // 0' 2>/dev/null || echo 0) local storage_used_bytes storage_used_bytes=$(echo "$object_storage" | jq '[.[].usedSpaceBytes // 0] | add // 0' 2>/dev/null || echo 0) local storage_used_gb storage_used_gb=$(awk "BEGIN {printf \"%.1f\", ${storage_used_bytes} / 1073741824}") # Estimate costs from instance product IDs # Contabo uses fixed monthly pricing per product tier local instance_cost="0.00" while IFS=$'\t' read -r pid pname status; do [[ -z "$pid" ]] && continue # Extract monthly cost from product info if available local cost_per_month="0" # Contabo productId maps to fixed monthly rates # These are approximations — actual billing comes from the Contabo panel case "$pid" in V1) cost_per_month="4.99" ;; V2) cost_per_month="5.99" ;; V4) cost_per_month="8.99" ;; V8) cost_per_month="13.99" ;; V16) cost_per_month="19.99" ;; V24) cost_per_month="24.99" ;; V30) cost_per_month="29.99" ;; V45) cost_per_month="39.99" ;; V60) cost_per_month="49.99" ;; *) cost_per_month="0" ;; esac instance_cost=$(awk "BEGIN {printf \"%.2f\", ${instance_cost} + ${cost_per_month}}") done < <(echo "$instances" | jq -r \ '.[] | "\(.productId // "—")\t\(.name // .displayName // "unknown")\t\(.status // "—")"' \ 2>/dev/null) local total_cost="$instance_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 instances "$instance_count" \ --argjson running "$running_count" \ --argjson snapshots "$snapshot_count" \ --argjson object_storage "$storage_count" \ --arg storage_tb "$storage_tb" \ --arg storage_used_gb "$storage_used_gb" \ --arg instance_cost "$instance_cost" \ --arg total_cost "$total_cost" \ --arg alert_threshold "$ALERT_THRESHOLD" \ --argjson alert_triggered "$alert_triggered" \ '{ instances: $instances, running: $running, snapshots: $snapshots, object_storage: $object_storage, storage_purchased_tb: ($storage_tb | tonumber), storage_used_gb: ($storage_used_gb | tonumber), monthly_estimate: { instances: $instance_cost, total: $total_cost }, alert: { threshold: $alert_threshold, triggered: $alert_triggered } }' ;; prometheus) cat </dev/null || echo 0) [[ "$instance_count" -eq 0 ]] && die "No instances found" case "$OUTPUT_FORMAT" in json) echo "$instances" | jq '[.[] | { id: .instanceId, name: (.name // .displayName), status: .status, product: .productId, region: .region, ip: .ipConfig.v4.ip }]' ;; *) section_header "Instance Cost Breakdown" printf " ${BOLD}%-10s %-20s %-8s %-10s %-10s %10s${RESET}\n" \ "ID" "NAME" "PRODUCT" "STATUS" "REGION" "MONTHLY €" printf " %s\n" "$(printf '%.0s─' {1..72})" while IFS=$'\t' read -r iid iname pid status region; do [[ -z "$iid" ]] && continue local cost_per_month="0.00" case "$pid" in V1) cost_per_month="4.99" ;; V2) cost_per_month="5.99" ;; V4) cost_per_month="8.99" ;; V8) cost_per_month="13.99" ;; V16) cost_per_month="19.99" ;; V24) cost_per_month="24.99" ;; V30) cost_per_month="29.99" ;; V45) cost_per_month="39.99" ;; V60) cost_per_month="49.99" ;; *) cost_per_month="—" ;; esac local status_color="$GREEN" case "$status" in running) status_color="$GREEN" ;; stopped) status_color="$YELLOW" ;; *) status_color="$RED" ;; esac printf " %-10s %-20s %-8s " "$iid" "${iname:0:18}" "$pid" echo -ne "${status_color}" printf "%-10s" "$status" echo -ne "${RESET}" printf " %-10s %10s\n" "${region:0:8}" "$cost_per_month" done < <(echo "$instances" | jq -r \ '.[] | "\(.instanceId)\t\(.name // .displayName // "unknown")\t\(.productId // "—")\t\(.status // "—")\t\(.region // "—")"' \ 2>/dev/null) echo "" field "Instances:" "$instance_count" ;; esac } # ══════════════════════════════════════════════════════════════════════ # RESOURCES # ══════════════════════════════════════════════════════════════════════ do_resources() { # Snapshots local snapshots snapshots=$(fetch_all_contabo "/compute/snapshots" "data") local snap_count snap_count=$(echo "$snapshots" | jq 'length' 2>/dev/null || echo 0) case "$OUTPUT_FORMAT" in json) local storage storage=$(fetch_all_contabo "/object-storages" "data") jq -n \ --argjson snapshots "$snapshots" \ --argjson object_storage "$storage" \ '{snapshots: $snapshots, object_storage: $object_storage}' ;; *) if [[ "$snap_count" -gt 0 ]]; then section_header "Snapshots" printf " ${BOLD}%-38s %-18s %-20s${RESET}\n" \ "SNAPSHOT_ID" "NAME" "CREATED" printf " %s\n" "$(printf '%.0s─' {1..78})" echo "$snapshots" | jq -r \ '.[] | "\(.snapshotId // .id // "—")\t\(.name // "—")\t\(.createdDate // "—")"' \ 2>/dev/null \ | while IFS=$'\t' read -r sid sname screated; do printf " %-38s %-18s %-20s\n" \ "${sid:0:36}" "${sname:0:16}" "${screated:0:19}" done echo "" field "Snapshots:" "$snap_count" fi # Object Storage local storage storage=$(fetch_all_contabo "/object-storages" "data") local storage_count storage_count=$(echo "$storage" | jq 'length' 2>/dev/null || echo 0) if [[ "$storage_count" -gt 0 ]]; then section_header "Object Storage" printf " ${BOLD}%-38s %-10s %-12s %-12s${RESET}\n" \ "STORAGE_ID" "REGION" "SIZE (TB)" "USED (GB)" printf " %s\n" "$(printf '%.0s─' {1..74})" echo "$storage" | jq -r \ '.[] | "\(.objectStorageId // .id // "—")\t\(.region // "—")\t\(.totalPurchasedSpaceTB // 0)\t\(.usedSpaceBytes // 0)"' \ 2>/dev/null \ | while IFS=$'\t' read -r oid oregion osize oused; do local used_gb used_gb=$(awk "BEGIN {printf \"%.1f\", ${oused} / 1073741824}") printf " %-38s %-10s %-12s %-12s\n" \ "${oid:0:36}" "${oregion:0:8}" "$osize" "$used_gb" done echo "" field "Object Storage:" "$storage_count" fi ;; esac } # ══════════════════════════════════════════════════════════════════════ # HELP # ══════════════════════════════════════════════════════════════════════ show_help() { cat < /var/lib/node_exporter/textfile/contabo_cost.prom 2>/dev/null ${BOLD}NOTES${RESET} Contabo uses fixed monthly pricing per product tier. Cost estimates are based on productId mapping — verify against your invoice. Snapshots and object storage are typically included in Contabo plans. ${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 "$@"