Files
linux-scripts/contabo-cost-monitor.sh
T
chiefgeek a1a17e81a1 Sync all scripts from website downloads — 352 scripts total
Includes updated JS challenge scripts with Claude-User whitelist,
same-site referer bypass, Blackbox-Exporter allowed bot, and all
new exporters, cheat sheets, and automation scripts.
2026-05-25 03:31:08 +02:00

552 lines
24 KiB
Bash

#!/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 <<EOF
# HELP contabo_cost_monthly_estimate_euros Estimated monthly cost in euros
# TYPE contabo_cost_monthly_estimate_euros gauge
contabo_cost_monthly_estimate_euros ${total_cost}
# HELP contabo_cost_instances_euros Estimated monthly instance cost
# TYPE contabo_cost_instances_euros gauge
contabo_cost_instances_euros ${instance_cost}
# HELP contabo_cost_instances_total Total instances
# TYPE contabo_cost_instances_total gauge
contabo_cost_instances_total ${instance_count}
# HELP contabo_cost_instances_running Running instances
# TYPE contabo_cost_instances_running gauge
contabo_cost_instances_running ${running_count}
# HELP contabo_cost_snapshots_total Total snapshots
# TYPE contabo_cost_snapshots_total gauge
contabo_cost_snapshots_total ${snapshot_count}
# HELP contabo_cost_object_storage_total Total object storage buckets
# TYPE contabo_cost_object_storage_total gauge
contabo_cost_object_storage_total ${storage_count}
# HELP contabo_cost_storage_used_bytes Object storage used bytes
# TYPE contabo_cost_storage_used_bytes gauge
contabo_cost_storage_used_bytes ${storage_used_bytes}
# HELP contabo_cost_alert_triggered Whether cost alert threshold exceeded
# TYPE contabo_cost_alert_triggered gauge
contabo_cost_alert_triggered $([ "$alert_triggered" == "true" ] && echo 1 || echo 0)
EOF
;;
*)
section_header "Contabo Cost Summary"
printf " ${BOLD}%-22s %8s %10s %12s${RESET}\n" \
"RESOURCE" "COUNT" "USAGE" "MONTHLY €"
printf " %s\n" "$(printf '%.0s─' {1..56})"
printf " %-22s %8s %10s %12s\n" \
"Instances (running)" "$running_count" "—" "${instance_cost}"
printf " %-22s %8s %10s %12s\n" \
"Instances (total)" "$instance_count" "—" "—"
printf " %-22s %8s %10s %12s\n" \
"Snapshots" "$snapshot_count" "—" "included"
printf " %-22s %8s %10s %12s\n" \
"Object Storage" "$storage_count" "${storage_used_gb} GB" "included"
printf " %s\n" "$(printf '%.0s─' {1..56})"
printf " ${BOLD}%-22s %8s %10s %12s${RESET}\n" \
"TOTAL" "" "" "${total_cost}"
if [[ "$alert_triggered" == "true" ]]; then
echo ""
echo -e " ${RED}⚠ ALERT: Monthly estimate €${total_cost} exceeds threshold €${ALERT_THRESHOLD}${RESET}"
fi
;;
esac
}
# ══════════════════════════════════════════════════════════════════════
# BREAKDOWN
# ══════════════════════════════════════════════════════════════════════
do_breakdown() {
local instances
instances=$(fetch_all_contabo "/compute/instances" "data")
local instance_count
instance_count=$(echo "$instances" | jq 'length' 2>/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 <<EOF
${BOLD}${SCRIPT_NAME}${RESET} — Contabo Cost Monitor
Track and report Contabo spending via the REST API.
${BOLD}MODES${RESET}
--summary Cost summary across all resources (default)
--breakdown Per-instance cost breakdown
--resources List all billable resources (snapshots, object storage)
${BOLD}OPTIONS${RESET}
--format FMT Output: table, json, prometheus (default: table)
--alert EUROS Alert if monthly estimate exceeds threshold
--verbose Debug output
--no-color Disable colored output
--help Show this help message
${BOLD}ENVIRONMENT VARIABLES${RESET}
CONTABO_CLIENT_ID OAuth2 Client ID (required)
CONTABO_CLIENT_SECRET OAuth2 Client Secret (required)
CONTABO_API_USER API username / email (required)
CONTABO_API_PASS API password (required)
CCM_FORMAT Default output format
CCM_ALERT Default alert threshold in euros
VERBOSE Enable verbose output (true/false)
COLOR Color mode: auto, always, never
${BOLD}EXAMPLES${RESET}
# Cost summary
${SCRIPT_NAME} --summary
# Per-instance breakdown
${SCRIPT_NAME} --breakdown
# List all resources
${SCRIPT_NAME} --resources
# JSON output
${SCRIPT_NAME} --summary --format json
# Alert if monthly cost exceeds €100
${SCRIPT_NAME} --summary --alert 100
# Prometheus metrics
${SCRIPT_NAME} --summary --format prometheus
# Cron — hourly cost metrics
0 * * * * /usr/local/bin/contabo-cost-monitor.sh --summary --format prometheus --no-color > /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 "$@"