Files
linux-scripts/gcp-cost-reporter.sh
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

564 lines
20 KiB
Bash
Executable File

#!/usr/bin/env bash
#########################################################################################
#### gcp-cost-reporter.sh — GCP cost breakdown by service, project, or label. ####
#### Queries BigQuery billing export for spend data with period comparison ####
#### Requires: bash 4+, gcloud CLI (bq), jq ####
#### ####
#### Author: Phil Connor ####
#### Contact: contact@mylinux.work ####
#### License: MIT ####
#### Version 1.00 ####
#### ####
#### Usage: ####
#### ./gcp-cost-reporter.sh --daily ####
#### ####
#### See --help for all options. ####
#########################################################################################
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────────────
BQ_BILLING_TABLE="${BQ_BILLING_TABLE:-}"
GCP_PROJECT="${GCP_PROJECT:-}"
GROUP_BY="${GROUP_BY:-SERVICE}"
OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}"
SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}"
LABEL_FILTER_KEY="${LABEL_FILTER_KEY:-}"
LABEL_FILTER_VALUE="${LABEL_FILTER_VALUE:-}"
VERBOSE="${VERBOSE:-false}"
COLOR="${COLOR:-auto}"
# ── State ─────────────────────────────────────────────────────────────
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
RUN_MODE=""
CUSTOM_START=""
CUSTOM_END=""
SLACK_URL=""
START_TIME=""
# ── Colors ────────────────────────────────────────────────────────────
RED="" GREEN="" YELLOW="" BLUE="" BOLD="" DIM="" RESET=""
setup_colors() {
if [[ "$COLOR" == "never" ]]; then
RED="" GREEN="" YELLOW="" BLUE="" BOLD="" DIM="" RESET=""
return
fi
if [[ "$COLOR" == "auto" && ! -t 1 ]]; then
RED="" GREEN="" YELLOW="" BLUE="" BOLD="" DIM="" RESET=""
return
fi
RED="\033[0;31m"
GREEN="\033[0;32m"
YELLOW="\033[0;33m"
# shellcheck disable=SC2034
BLUE="\033[0;34m"
# shellcheck disable=SC2034
BOLD="\033[1m"
DIM="\033[2m"
RESET="\033[0m"
}
# ── Logging ───────────────────────────────────────────────────────────
log_info() { printf "${GREEN}[INFO]${RESET} %s\n" "$*"; }
log_warn() { printf "${YELLOW}[WARN]${RESET} %s\n" "$*" >&2; }
log_error() { printf "${RED}[ERROR]${RESET} %s\n" "$*" >&2; }
log_debug() { [[ "$VERBOSE" == "true" ]] && printf "${DIM}[DEBUG] %s${RESET}\n" "$*"; }
# ── Helpers ───────────────────────────────────────────────────────────
die() { log_error "$@"; exit 1; }
check_deps() {
local missing=()
command -v gcloud >/dev/null 2>&1 || missing+=("gcloud")
command -v bq >/dev/null 2>&1 || missing+=("bq")
command -v jq >/dev/null 2>&1 || missing+=("jq")
command -v curl >/dev/null 2>&1 || missing+=("curl")
if (( ${#missing[@]} > 0 )); then
die "Missing required tools: ${missing[*]}"
fi
local bash_major="${BASH_VERSINFO[0]}"
if (( bash_major < 4 )); then
die "Requires bash 4+, found ${BASH_VERSION}"
fi
}
validate_date() {
local d="$1"
if [[ ! "$d" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
die "Invalid date format: $d (expected YYYY-MM-DD)"
fi
}
# ── Date math (portable) ─────────────────────────────────────────────
date_offset() {
local base="$1" offset="$2"
if date --version >/dev/null 2>&1; then
date -d "${base} ${offset} days" +%Y-%m-%d
else
date -j -v"${offset}d" -f "%Y-%m-%d" "$base" +%Y-%m-%d
fi
}
today_utc() { date -u +%Y-%m-%d; }
first_of_month() {
local d="$1"
echo "${d:0:8}01"
}
first_of_prev_month() {
local d="$1"
local year="${d:0:4}"
local month="${d:5:2}"
month=$((10#$month - 1))
if (( month == 0 )); then
month=12
year=$((year - 1))
fi
printf "%04d-%02d-01" "$year" "$month"
}
days_between() {
local s="$1" e="$2"
local ss se
if date --version >/dev/null 2>&1; then
ss=$(date -d "$s" +%s)
se=$(date -d "$e" +%s)
else
ss=$(date -j -f "%Y-%m-%d" "$s" +%s)
se=$(date -j -f "%Y-%m-%d" "$e" +%s)
fi
echo $(( (se - ss) / 86400 ))
}
# ── Compute date ranges ──────────────────────────────────────────────
compute_ranges() {
local today
today="$(today_utc)"
case "$RUN_MODE" in
daily)
PERIOD_START="$(date_offset "$today" -1)"
PERIOD_END="$today"
PREV_START="$(date_offset "$today" -2)"
PREV_END="$(date_offset "$today" -1)"
;;
weekly)
PERIOD_START="$(date_offset "$today" -7)"
PERIOD_END="$today"
PREV_START="$(date_offset "$today" -14)"
PREV_END="$(date_offset "$today" -7)"
;;
monthly)
PERIOD_START="$(first_of_month "$today")"
PERIOD_END="$today"
local prev_first
prev_first="$(first_of_prev_month "$today")"
PREV_START="$prev_first"
PREV_END="$PERIOD_START"
;;
custom)
PERIOD_START="$CUSTOM_START"
PERIOD_END="$CUSTOM_END"
local span
span="$(days_between "$CUSTOM_START" "$CUSTOM_END")"
PREV_START="$(date_offset "$CUSTOM_START" "-$span")"
PREV_END="$CUSTOM_START"
;;
*)
die "Unknown mode: $RUN_MODE"
;;
esac
log_debug "Current period: $PERIOD_START$PERIOD_END"
log_debug "Previous period: $PREV_START$PREV_END"
}
# ── Build BigQuery SQL ────────────────────────────────────────────────
build_select_column() {
case "$GROUP_BY" in
SERVICE) echo "service.description AS group_key" ;;
PROJECT) echo "project.id AS group_key" ;;
LABEL)
if [[ -z "$LABEL_FILTER_KEY" ]]; then
die "--group-by LABEL requires --label KEY=VALUE"
fi
echo "( SELECT value FROM UNNEST(labels) WHERE key = '${LABEL_FILTER_KEY}' ) AS group_key"
;;
*)
die "Invalid --group-by value: $GROUP_BY (expected SERVICE, PROJECT, or LABEL)"
;;
esac
}
build_where_clause() {
local start="$1" end="$2"
local where="usage_start_time >= TIMESTAMP('${start}') AND usage_start_time < TIMESTAMP('${end}')"
if [[ -n "$LABEL_FILTER_KEY" && -n "$LABEL_FILTER_VALUE" ]]; then
where="${where} AND EXISTS( SELECT 1 FROM UNNEST(labels) l WHERE l.key = '${LABEL_FILTER_KEY}' AND l.value = '${LABEL_FILTER_VALUE}' )"
fi
echo "$where"
}
build_query() {
local start="$1" end="$2"
local select_col where_clause
select_col="$(build_select_column)"
where_clause="$(build_where_clause "$start" "$end")"
cat <<EOF
SELECT
${select_col},
SUM(cost) AS total_cost
FROM
\`${BQ_BILLING_TABLE}\`
WHERE
${where_clause}
GROUP BY
group_key
HAVING
total_cost != 0
ORDER BY
total_cost DESC
EOF
}
# ── Query BigQuery ────────────────────────────────────────────────────
query_costs() {
local start="$1" end="$2"
local sql
sql="$(build_query "$start" "$end")"
log_debug "SQL: $sql"
local cmd=(
bq query
--format=json
--nouse_legacy_sql
--max_rows=10000
)
if [[ -n "$GCP_PROJECT" ]]; then
cmd+=(--project_id="$GCP_PROJECT")
fi
log_debug "Running: ${cmd[*]}"
"${cmd[@]}" "$sql" 2>/dev/null
}
# ── Parse cost data ──────────────────────────────────────────────────
parse_costs() {
local raw="$1"
echo "$raw" | jq -r '
.[] |
select(.group_key != null and .group_key != "") |
"\(.group_key)\t\(.total_cost)"
' 2>/dev/null || echo ""
}
# ── Format helpers ────────────────────────────────────────────────────
fmt_currency() {
printf "$%.2f" "$1"
}
fmt_delta() {
local curr="$1" prev="$2"
if (( $(echo "$prev == 0" | bc -l) )); then
echo "N/A"
return
fi
local pct
pct=$(echo "scale=1; (($curr - $prev) / $prev) * 100" | bc -l)
local sign=""
if (( $(echo "$pct > 0" | bc -l) )); then
sign="+"
fi
echo "${sign}${pct}%"
}
print_header() {
local account_id
account_id=$(gcloud config get-value account 2>/dev/null || echo "unknown")
local project_id
project_id="${GCP_PROJECT:-$(gcloud config get-value project 2>/dev/null || echo "unknown")}"
echo "GCP Cost Reporter"
echo "Account: $account_id"
echo "Project: $project_id"
echo "Table: $BQ_BILLING_TABLE"
echo "Mode: $RUN_MODE"
echo "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ "$RUN_MODE" == "custom" ]]; then
echo "Period: $PERIOD_START$PERIOD_END"
fi
echo ""
}
# ── Text table output ────────────────────────────────────────────────
output_text_table() {
local -n curr_data=$1
local -n prev_data=$2
local label="SERVICE"
case "$GROUP_BY" in
PROJECT) label="PROJECT" ;;
LABEL) label="LABEL" ;;
esac
local divider="──────────────────────────────────────────────────────────────────────"
printf " %-38s %-12s %-12s %s\n" "$label" "COST" "PREV" "DELTA"
printf " %s\n" "$divider"
local total_curr=0 total_prev=0
for key in "${!curr_data[@]}"; do
local cost="${curr_data[$key]}" prev_cost="${prev_data[$key]:-0}"
printf " %-38s %-12s %-12s %s\n" \
"$key" "$(fmt_currency "$cost")" "$(fmt_currency "$prev_cost")" "$(fmt_delta "$cost" "$prev_cost")"
total_curr=$(echo "$total_curr + $cost" | bc -l)
total_prev=$(echo "$total_prev + $prev_cost" | bc -l)
done
printf " %s\n" "$divider"
printf " %-38s %-12s %-12s %s\n" \
"TOTAL" "$(fmt_currency "$total_curr")" "$(fmt_currency "$total_prev")" "$(fmt_delta "$total_curr" "$total_prev")"
}
# ── CSV output ────────────────────────────────────────────────────────
output_csv() {
local -n curr_data=$1
local -n prev_data=$2
local label="service"
case "$GROUP_BY" in
PROJECT) label="project" ;;
LABEL) label="label" ;;
esac
echo "${label},cost,previous_cost,delta_pct"
for key in "${!curr_data[@]}"; do
local cost="${curr_data[$key]}" prev_cost="${prev_data[$key]:-0}" pct="0"
if (( $(echo "$prev_cost != 0" | bc -l) )); then
pct=$(echo "scale=2; (($cost - $prev_cost) / $prev_cost) * 100" | bc -l)
fi
echo "\"$key\",$cost,$prev_cost,$pct"
done
}
# ── JSON output ───────────────────────────────────────────────────────
output_json() {
local -n curr_data=$1
local -n prev_data=$2
local label="service"
case "$GROUP_BY" in
PROJECT) label="project" ;;
LABEL) label="label" ;;
esac
local items=()
for key in "${!curr_data[@]}"; do
items+=("{\"${label}\": \"${key}\", \"cost\": ${curr_data[$key]}, \"previous_cost\": ${prev_data[$key]:-0}}")
done
local joined
joined=$(printf ",%s" "${items[@]}")
joined="${joined:1}"
printf '{"mode":"%s","period_start":"%s","period_end":"%s","previous_start":"%s","previous_end":"%s","group_by":"%s","items":[%s]}\n' \
"$RUN_MODE" "$PERIOD_START" "$PERIOD_END" "$PREV_START" "$PREV_END" "$GROUP_BY" "$joined"
}
# ── Render report ─────────────────────────────────────────────────────
render_report() {
local curr_raw="$1" prev_raw="$2"
declare -A curr_costs
declare -A prev_costs
while IFS=$'\t' read -r key amount; do
[[ -z "$key" ]] && continue
curr_costs["$key"]="$amount"
done <<< "$(parse_costs "$curr_raw")"
while IFS=$'\t' read -r key amount; do
[[ -z "$key" ]] && continue
prev_costs["$key"]="$amount"
done <<< "$(parse_costs "$prev_raw")"
for key in "${!prev_costs[@]}"; do
if [[ -z "${curr_costs[$key]+x}" ]]; then
curr_costs["$key"]="0"
fi
done
case "$OUTPUT_FORMAT" in
text)
print_header
local title="Cost Breakdown — ${PERIOD_START}${PERIOD_END}"
echo "$title"
output_text_table curr_costs prev_costs
echo ""
;;
csv)
output_csv curr_costs prev_costs
;;
json)
output_json curr_costs prev_costs
;;
*)
die "Unknown format: $OUTPUT_FORMAT"
;;
esac
}
# ── Slack webhook ─────────────────────────────────────────────────────
send_slack() {
local report="$1" webhook="$2"
log_info "Posting report to Slack..."
local max_len=3000
local body="$report"
if (( ${#body} > max_len )); then
body="${body:0:$max_len}
... (truncated — full report exceeds Slack message limit)"
fi
local payload
payload=$(jq -n --arg text "\`\`\`${body}\`\`\`" '{ text: $text }')
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST \
-H "Content-Type: application/json" \
-d "$payload" \
"$webhook")
if [[ "$http_code" != "200" ]]; then
log_error "Slack webhook returned HTTP $http_code"
return 1
fi
log_info "Slack message posted"
}
# ── Usage ─────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [MODE] [OPTIONS]
GCP cost breakdown using BigQuery billing export data.
Requires BQ_BILLING_TABLE to be set (e.g., "project.dataset.gcp_billing_export_v1_XXXXXX").
Modes:
--daily Yesterday vs day before
--weekly Last 7 days vs previous 7 days
--monthly Current month to date vs previous month
--custom START END Custom date range (YYYY-MM-DD)
Options:
--group-by TYPE SERVICE (default), PROJECT, LABEL
--label KEY=VALUE Filter by label (required for --group-by LABEL)
--format FORMAT text (default), csv, json
--slack WEBHOOK_URL Post report to Slack webhook
--project PROJECT_ID GCP project for bq queries
--verbose Debug output
--no-color Disable colored output
--help Show this help
Environment variables:
BQ_BILLING_TABLE BigQuery billing export table (required)
GCP_PROJECT Default GCP project for bq
GROUP_BY Default group-by dimension
OUTPUT_FORMAT Default output format
SLACK_WEBHOOK_URL Default Slack webhook URL
EOF
exit 0
}
# ── Argument parsing ─────────────────────────────────────────────────
parse_args() {
while (( $# > 0 )); do
case "$1" in
--daily|--weekly|--monthly)
RUN_MODE="${1#--}"; shift ;;
--custom)
RUN_MODE="custom"
[[ $# -lt 3 ]] && die "--custom requires START and END dates"
CUSTOM_START="$2"; CUSTOM_END="$3"
validate_date "$CUSTOM_START"; validate_date "$CUSTOM_END"
shift 3 ;;
--group-by)
[[ $# -lt 2 ]] && die "--group-by requires a value"
GROUP_BY="$2"; shift 2 ;;
--label)
[[ $# -lt 2 ]] && die "--label requires KEY=VALUE"
[[ "$2" != *"="* ]] && die "--label value must be KEY=VALUE"
LABEL_FILTER_KEY="${2%%=*}"; LABEL_FILTER_VALUE="${2#*=}"; shift 2 ;;
--format)
[[ $# -lt 2 ]] && die "--format requires a value"
OUTPUT_FORMAT="$2"; shift 2 ;;
--slack)
[[ $# -lt 2 ]] && die "--slack requires a webhook URL"
SLACK_URL="$2"; shift 2 ;;
--project)
[[ $# -lt 2 ]] && die "--project requires a project ID"
GCP_PROJECT="$2"; shift 2 ;;
--verbose) VERBOSE="true"; shift ;;
--no-color) COLOR="never"; shift ;;
--help|-h) usage ;;
*) die "Unknown option: $1 (see --help)" ;;
esac
done
if [[ -z "$RUN_MODE" ]]; then log_error "No mode specified"; echo ""; usage; exit 1; fi
[[ -z "$BQ_BILLING_TABLE" ]] && die "BQ_BILLING_TABLE is required (e.g., project.dataset.gcp_billing_export_v1_XXXXXX)"
[[ -z "$SLACK_URL" && -n "$SLACK_WEBHOOK_URL" ]] && SLACK_URL="$SLACK_WEBHOOK_URL"
case "$GROUP_BY" in
SERVICE|PROJECT|LABEL) ;;
*) die "Invalid --group-by: $GROUP_BY (expected SERVICE, PROJECT, or LABEL)" ;;
esac
case "$OUTPUT_FORMAT" in
text|csv|json) ;;
*) die "Invalid --format: $OUTPUT_FORMAT" ;;
esac
}
# ── Main ──────────────────────────────────────────────────────────────
main() {
parse_args "$@"
setup_colors
check_deps
START_TIME=$(date +%s)
log_debug "Validating GCP credentials..."
gcloud auth print-access-token >/dev/null 2>&1 \
|| die "GCP credentials not configured or expired (run gcloud auth login)"
compute_ranges
log_info "Querying BigQuery billing data ($RUN_MODE, group by $GROUP_BY)..."
local curr_raw prev_raw
curr_raw="$(query_costs "$PERIOD_START" "$PERIOD_END")"
prev_raw="$(query_costs "$PREV_START" "$PREV_END")"
if [[ -z "$curr_raw" || "$curr_raw" == "[]" ]]; then
die "No cost data returned for $PERIOD_START$PERIOD_END"
fi
local report
report="$(render_report "$curr_raw" "$prev_raw")"
echo "$report"
if [[ -n "$SLACK_URL" ]]; then
send_slack "$report" "$SLACK_URL"
fi
local elapsed=$(( $(date +%s) - START_TIME ))
log_info "Completed in ${elapsed}s"
}
main "$@"