#!/usr/bin/env bash ######################################################################################### #### contabo-fleet-manager.sh — Inventory, health checks, and bulk operations for #### #### Contabo VPS/VDS instances 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: #### #### ./contabo-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="" INSTANCE_ID="" TARGET_ALL="false" TAG_ID="" TAG_SUB_MODE="" OUTPUT_FORMAT="${CFM_FORMAT:-text}" PING_CHECK="false" FORCE="false" 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="" ACTION_OK=0 ACTION_FAIL=0 # ── 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/cfm_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/cfm_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" } # ── Instance helpers ───────────────────────────────────────────────── get_all_instance_ids() { local page=1 size=100 ids="" while true; do local resp resp=$(contabo_api GET "/compute/instances?page=${page}&size=${size}") local page_ids page_ids=$(echo "$resp" | jq -r '.data[].instanceId' 2>/dev/null) [[ -z "$page_ids" ]] && break ids="${ids}${ids:+$'\n'}${page_ids}" local count count=$(echo "$page_ids" | wc -l) (( count < size )) && break ((page++)) || true done echo "$ids" } get_instance_name() { local iid="$1" contabo_api GET "/compute/instances/${iid}" \ | jq -r '.data[0].name // .data[0].displayName // "unknown"' 2>/dev/null } get_instance_ids() { if [[ "$TARGET_ALL" == "true" ]]; then get_all_instance_ids elif [[ -n "$INSTANCE_ID" ]]; then echo "$INSTANCE_ID" elif [[ -n "$TAG_ID" ]]; then get_instances_by_tag "$TAG_ID" else die "Specify --instance ID, --all, or --tag TAG_ID" fi } get_instances_by_tag() { local tid="$1" local page=1 size=100 ids="" while true; do local resp resp=$(contabo_api GET "/compute/instances?page=${page}&size=${size}") local page_ids page_ids=$(echo "$resp" | jq -r --arg tid "$tid" \ '.data[] | select(.tags[]? | .tagId == ($tid | tonumber)) | .instanceId' 2>/dev/null) [[ -z "$page_ids" ]] && break ids="${ids}${ids:+$'\n'}${page_ids}" local count count=$(echo "$page_ids" | wc -l) (( count < size )) && break ((page++)) || true done echo "$ids" } # ══════════════════════════════════════════════════════════════════════ # INVENTORY # ══════════════════════════════════════════════════════════════════════ do_inventory() { local page=1 size=100 all_data="[]" while true; do local resp resp=$(contabo_api GET "/compute/instances?page=${page}&size=${size}") local page_data page_data=$(echo "$resp" | jq '.data // []' 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 local total total=$(echo "$all_data" | jq 'length' 2>/dev/null || echo 0) [[ "$total" -eq 0 ]] && die "No instances found" # Filter by tag if specified if [[ -n "$TAG_ID" ]]; then all_data=$(echo "$all_data" | jq --arg tid "$TAG_ID" \ '[.[] | select(.tags[]? | .tagId == ($tid | tonumber))]' 2>/dev/null) total=$(echo "$all_data" | jq 'length' 2>/dev/null || echo 0) [[ "$total" -eq 0 ]] && die "No instances found with tag ${TAG_ID}" fi case "$OUTPUT_FORMAT" in json) echo "$all_data" | jq '.' ;; ansible) echo "[contabo]" echo "$all_data" | jq -r \ '.[] | (.ipConfig.v4.ip // "unknown") + " # " + (.name // .displayName // "unknown") + " id=" + (.instanceId | tostring)' \ 2>/dev/null ;; *) section_header "Fleet Inventory" printf " ${BOLD}%-13s %-20s %-11s %-16s %-8s %-8s${RESET}\n" \ "INSTANCE_ID" "NAME" "STATUS" "IP" "REGION" "PRODUCT" printf " %s\n" "$(printf '%.0s─' {1..78})" echo "$all_data" | jq -r \ '.[] | "\(.instanceId)\t\(.name // .displayName // "unknown")\t\(.status // "unknown")\t\(.ipConfig.v4.ip // "—")\t\(.region // "—")\t\(.productId // "—")"' \ 2>/dev/null \ | while IFS=$'\t' read -r iid name status ip region product; do printf " %-13s %-20s %-11s %-16s %-8s %-8s\n" \ "$iid" "${name:0:18}" "$status" "$ip" "${region:0:6}" "$product" done echo "" field "Total:" "$total" ;; esac } # ══════════════════════════════════════════════════════════════════════ # HEALTH # ══════════════════════════════════════════════════════════════════════ do_health() { local ids ids=$(get_instance_ids) [[ -z "$ids" ]] && die "No instances found" local running=0 stopped=0 errored=0 total_instances=0 local results="" while IFS= read -r iid; do [[ -z "$iid" ]] && continue ((total_instances++)) || true local resp resp=$(contabo_api GET "/compute/instances/${iid}") local name status ip name=$(echo "$resp" | jq -r '.data[0].name // .data[0].displayName // "unknown"' 2>/dev/null) status=$(echo "$resp" | jq -r '.data[0].status // "unknown"' 2>/dev/null) ip=$(echo "$resp" | jq -r '.data[0].ipConfig.v4.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 ;; stopped) ((stopped++)) || true ;; *) ((errored++)) || true ;; esac results="${results}${iid}\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} ${iname} (${iid}) ${action} sent" ((ACTION_OK++)) || true else echo -e " ${RED}✗${RESET} ${iname} (${iid}) ${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 } # ══════════════════════════════════════════════════════════════════════ # TAGS # ══════════════════════════════════════════════════════════════════════ do_tags() { if [[ "$TAG_SUB_MODE" == "list" ]]; then local resp resp=$(contabo_api GET "/tags?page=1&size=100") if [[ "$OUTPUT_FORMAT" == "json" ]]; then echo "$resp" | jq '.data // []' return fi section_header "Tags" printf " ${BOLD}%-10s %-30s %-10s${RESET}\n" "TAG_ID" "NAME" "COLOR" printf " %s\n" "$(printf '%.0s─' {1..52})" echo "$resp" | jq -r '.data[] | "\(.tagId)\t\(.name)\t\(.color // "—")"' 2>/dev/null \ | while IFS=$'\t' read -r tid tname tcolor; do printf " %-10s %-30s %-10s\n" "$tid" "${tname:0:28}" "$tcolor" done elif [[ "$TAG_SUB_MODE" == "filter" ]]; then [[ -z "$TAG_ID" ]] && die "Specify --filter TAG_ID" INSTANCE_ID="" TARGET_ALL="false" do_inventory else die "Specify --list or --filter TAG_ID with --tags" fi } # ══════════════════════════════════════════════════════════════════════ # HELP # ══════════════════════════════════════════════════════════════════════ show_help() { cat <