#!/usr/bin/env bash # # eks-exposure.sh — print what your stale EKS clusters cost, in dollars. # # READ-ONLY. Makes exactly four kinds of AWS API calls, none of which # change anything: # ec2:DescribeRegions (which regions to look in) # eks:DescribeClusterVersions (AWS's own support calendar) # eks:ListClusters (cluster names per region) # eks:DescribeCluster (version + upgrade policy per cluster) # Minimal IAM policy: iam-policy.json next to this file. # # Nothing leaves your machine. There is no phone-home, no upload, no email # gate. Read the script — it's short on purpose. # # Usage: # ./eks-exposure.sh # scan every region you can see # ./eks-exposure.sh --regions us-east-1,us-west-2 # ./eks-exposure.sh --profile prod # ./eks-exposure.sh --json > exposure.json # ./eks-exposure.sh --json --redact # cluster-1, cluster-2...; no ARNs, no account id # # Requires: aws CLI v2 (>= 2.22, for describe-cluster-versions), jq. # set -euo pipefail STANDARD_RATE=0.10 # USD per cluster-hour, standard support EXTENDED_RATE=0.60 # USD per cluster-hour, extended support HOURS_PER_YEAR=8760 SOON_DAYS=90 # "starts billing soon" / "forced upgrade soon" window REGIONS="" PROFILE_ARGS=() JSON_OUT=false REDACT=false while [[ $# -gt 0 ]]; do case "$1" in --regions) REGIONS="$2"; shift 2 ;; --profile) PROFILE_ARGS=(--profile "$2"); shift 2 ;; --json) JSON_OUT=true; shift ;; --redact) REDACT=true; shift ;; -h|--help) sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "unknown option: $1" >&2; exit 2 ;; esac done for bin in aws jq; do command -v "$bin" >/dev/null || { echo "missing dependency: $bin" >&2; exit 1; } done aws_ro() { aws ${PROFILE_ARGS[@]+"${PROFILE_ARGS[@]}"} --output json "$@"; } # --- 1. Support calendar, straight from AWS ------------------------------- # One region is enough; the calendar is global. CAL_REGION="${AWS_DEFAULT_REGION:-us-east-1}" if ! CALENDAR=$(aws_ro eks describe-cluster-versions --include-all --region "$CAL_REGION" 2>/dev/null); then echo "describe-cluster-versions failed. Upgrade the AWS CLI (needs v2.22+) or check the IAM policy." >&2 exit 1 fi # --- 2. Regions ------------------------------------------------------------- if [[ -n "$REGIONS" ]]; then REGION_LIST=$(tr ',' '\n' <<<"$REGIONS") else REGION_LIST=$(aws_ro ec2 describe-regions --region "$CAL_REGION" \ --query 'Regions[].RegionName' | jq -r '.[]' | sort) fi # --- 3. Clusters ------------------------------------------------------------ CLUSTERS='[]' for region in $REGION_LIST; do # Regions with EKS disabled / not opted in return an error; skip quietly. names=$(aws_ro eks list-clusters --region "$region" 2>/dev/null | jq -r '.clusters[]?' || true) [[ -z "$names" ]] && continue for name in $names; do c=$(aws_ro eks describe-cluster --region "$region" --name "$name" \ --query 'cluster.{name:name,version:version,status:status,createdAt:createdAt,supportType:upgradePolicy.supportType,arn:arn}') CLUSTERS=$(jq -c --arg r "$region" '. + [($c | .region = $r)]' --argjson c "$c" <<<"$CLUSTERS") done done # --- 3b. Redaction (optional) ----------------------------------------------- # Replace names with cluster-N and drop ARNs so the report carries no account id. if $REDACT; then CLUSTERS=$(jq -c 'to_entries | map(.value + {name: "cluster-\(.key + 1)"} | del(.arn))' <<<"$CLUSTERS") fi # --- 4. The math ------------------------------------------------------------ NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) REPORT=$(jq -n \ --argjson clusters "$CLUSTERS" \ --argjson calendar "$CALENDAR" \ --arg now "$NOW" \ --argjson std "$STANDARD_RATE" --argjson ext "$EXTENDED_RATE" \ --argjson hpy "$HOURS_PER_YEAR" --argjson soon "$SOON_DAYS" ' # Dates arrive as ISO-8601 with an offset; day precision is all we need, # so parse the first 19 characters and ignore the offset. def days_until($d): if $d == null then null else (($d[0:19] | strptime("%Y-%m-%dT%H:%M:%S") | mktime) - ($now | fromdate)) / 86400 | floor end; def money: . * 100 | round / 100; (($ext - $std) * $hpy) as $penalty_per_year | ($calendar.clusterVersions | map({(.clusterVersion): .}) | add) as $cal | [ $clusters[] | . as $c | ($cal[$c.version] // {}) as $v | (days_until($v.endOfStandardSupportDate)) as $d_std | (days_until($v.endOfExtendedSupportDate)) as $d_ext | ($v.versionStatus // $v.status // "UNKNOWN" | ascii_upcase | sub("-"; "_")) as $vstatus | ($c.supportType // "EXTENDED") as $policy | (if $policy == "STANDARD" then $d_std else $d_ext end) as $d_force | { name: $c.name, region: $c.region, arn: $c.arn, version: $c.version, clusterStatus: $c.status, supportType: $policy, versionStatus: $vstatus, endOfStandardSupport: $v.endOfStandardSupportDate, endOfExtendedSupport: $v.endOfExtendedSupportDate, daysToEndOfStandard: $d_std, daysToEndOfExtended: $d_ext, inExtendedSupport: ($vstatus == "EXTENDED_SUPPORT"), # money # money — only clusters whose upgrade policy is EXTENDED (the default) get billed; # a STANDARD policy means AWS force-upgrades at end of standard support instead. extraPerYearNow: (if $policy == "EXTENDED" and $vstatus == "EXTENDED_SUPPORT" then $penalty_per_year else 0 end), extraPerYearSoon: (if $policy == "EXTENDED" and $vstatus == "STANDARD_SUPPORT" and $d_std != null and $d_std <= $soon then $penalty_per_year else 0 end), # breakage — the day AWS may upgrade the control plane without asking forcedUpgradeDate: (if $policy == "STANDARD" then $v.endOfStandardSupportDate else $v.endOfExtendedSupportDate end), forcedUpgradeRisk: ( if $vstatus == "UNSUPPORTED" or ($d_force != null and $d_force < 0) then "NOW" elif $d_force != null and $d_force <= $soon then "SOON" else "NO" end), cohort: ( if $vstatus == "UNSUPPORTED" or ($d_force != null and $d_force <= $soon) then "breakage" elif $policy == "EXTENDED" and $vstatus == "EXTENDED_SUPPORT" then "money" elif $policy == "EXTENDED" and $d_std != null and $d_std <= $soon then "money-soon" else "fine" end) } ] as $rows | { generatedAt: $now, assumptions: { standardRatePerHour: $std, extendedRatePerHour: $ext, hoursPerYear: $hpy, extendedPenaltyPerClusterPerYear: $penalty_per_year, soonWindowDays: $soon, source: "aws eks describe-cluster-versions + aws.amazon.com/eks/pricing" }, summary: { clusters: ($rows | length), clustersInExtendedSupport: ($rows | map(select(.inExtendedSupport)) | length), clustersEnteringExtendedWithinWindow: ($rows | map(select(.extraPerYearSoon > 0)) | length), clustersAtForcedUpgradeRisk: ($rows | map(select(.forcedUpgradeRisk != "NO")) | length), extraPerYearNow: ($rows | map(.extraPerYearNow) | add // 0 | money), extraPerYearSoon: ($rows | map(.extraPerYearSoon) | add // 0 | money), extraPerMonthNow: (($rows | map(.extraPerYearNow) | add // 0) / 12 | money) }, clusters: $rows }') if $JSON_OUT; then echo "$REPORT" exit 0 fi # --- 5. Human output -------------------------------------------------------- echo echo "EKS exposure report · $(date -u +%Y-%m-%d) · read-only" echo jq -r ' def pad($n): tostring | . + " " * ($n - length) | .[0:$n]; def num: if . == null then "-" else tostring end; "\("CLUSTER" | pad(28))\("REGION" | pad(15))\("VER" | pad(6))\("STATUS" | pad(18))\("EXTRA $/YR" | pad(12))\("DAYS→EOSS" | pad(11))\("DAYS→EOES" | pad(11))FORCED?", (.clusters[] | "\(.name | pad(28))\(.region | pad(15))\(.version | pad(6))\(.versionStatus | pad(18))\(.extraPerYearNow | num | pad(12))\(.daysToEndOfStandard | num | pad(11))\(.daysToEndOfExtended | num | pad(11))\(.forcedUpgradeRisk)"), "", "Clusters: \(.summary.clusters)", "In extended support (billing $0.60/hr): \(.summary.clustersInExtendedSupport)", "Entering extended within \(.assumptions.soonWindowDays) days: \(.summary.clustersEnteringExtendedWithinWindow)", "At forced-upgrade risk: \(.summary.clustersAtForcedUpgradeRisk)", "", "Extended-support premium you are paying now: $\(.summary.extraPerYearNow) / yr ($\(.summary.extraPerMonthNow) / mo)", "Additional premium starting within \(.assumptions.soonWindowDays) days: $\(.summary.extraPerYearSoon) / yr", "", "EOSS = end of standard support (billing jumps to $0.60/hr). EOES = end of extended support", "(AWS force-upgrades the control plane, no notice, no rollback). Premium = $0.50/hr × 8,760 h." ' <<<"$REPORT" echo