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

637 lines
22 KiB
Bash
Executable File

#!/usr/bin/env bash
#########################################################################################
#### azure-cost-reporter.sh — Azure cost breakdown by service, resource group, or ####
#### tag. Supports email, Slack webhooks, CSV/JSON export, period comparison ####
#### Requires: bash 4+, az CLI, jq, curl ####
#### ####
#### Author: Phil Connor ####
#### Contact: contact@mylinux.work ####
#### License: MIT ####
#### Version 1.00 ####
#### ####
#### Usage: ####
#### ./azure-cost-reporter.sh --daily ####
#### ####
#### See --help for all options. ####
#########################################################################################
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────────────
SUBSCRIPTION="${SUBSCRIPTION:-}"
GROUP_BY="${GROUP_BY:-SERVICE}"
OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}"
SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}"
COST_TAG_KEY="${COST_TAG_KEY:-}"
COST_TAG_VALUE="${COST_TAG_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 reserved for future use / caller scripts
BLUE="\033[0;34m"
# shellcheck disable=SC2034 # BOLD reserved for future use / caller scripts
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 az >/dev/null 2>&1 || missing+=("az-cli")
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() {
# Usage: date_offset YYYY-MM-DD -N → date N days before
local base="$1" offset="$2"
if date --version >/dev/null 2>&1; then
# GNU date
date -d "${base} ${offset} days" +%Y-%m-%d
else
# macOS date
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"
}
# ── Resolve subscription ─────────────────────────────────────────────
resolve_subscription() {
if [[ -n "$SUBSCRIPTION" ]]; then
log_debug "Using subscription: $SUBSCRIPTION"
return
fi
SUBSCRIPTION=$(az account show --query 'id' -o tsv 2>/dev/null) \
|| die "Cannot determine subscription. Use --subscription or az account set."
log_debug "Resolved subscription: $SUBSCRIPTION"
}
# ── Build Cost Management query payload ──────────────────────────────
build_query_payload() {
local start="$1" end="$2"
local grouping_name grouping_type
case "$GROUP_BY" in
SERVICE)
grouping_name="ServiceName"
grouping_type="Dimension"
;;
RESOURCE_GROUP)
grouping_name="ResourceGroupName"
grouping_type="Dimension"
;;
TAG)
if [[ -z "$COST_TAG_KEY" ]]; then
die "--group-by TAG requires --tag KEY=VALUE"
fi
grouping_name="$COST_TAG_KEY"
grouping_type="TagKey"
;;
*)
die "Invalid --group-by value: $GROUP_BY (expected SERVICE, RESOURCE_GROUP, or TAG)"
;;
esac
local filter_block="{}"
if [[ -n "$COST_TAG_KEY" && -n "$COST_TAG_VALUE" && "$GROUP_BY" != "TAG" ]]; then
filter_block=$(jq -n \
--arg key "$COST_TAG_KEY" \
--arg val "$COST_TAG_VALUE" \
'{
"Tags": {
"Name": $key,
"Operator": "In",
"Values": [$val]
}
}')
fi
local payload
if [[ "$filter_block" == "{}" ]]; then
payload=$(jq -n \
--arg start "$start" \
--arg end "$end" \
--arg gname "$grouping_name" \
--arg gtype "$grouping_type" \
'{
"type": "ActualCost",
"dataSet": {
"granularity": "None",
"aggregation": {
"totalCost": {
"name": "Cost",
"function": "Sum"
}
},
"grouping": [
{
"type": $gtype,
"name": $gname
}
]
},
"timeframe": "Custom",
"timePeriod": {
"from": $start,
"to": $end
}
}')
else
payload=$(jq -n \
--arg start "$start" \
--arg end "$end" \
--arg gname "$grouping_name" \
--arg gtype "$grouping_type" \
--argjson filter "$filter_block" \
'{
"type": "ActualCost",
"dataSet": {
"granularity": "None",
"aggregation": {
"totalCost": {
"name": "Cost",
"function": "Sum"
}
},
"grouping": [
{
"type": $gtype,
"name": $gname
}
],
"filter": $filter
},
"timeframe": "Custom",
"timePeriod": {
"from": $start,
"to": $end
}
}')
fi
echo "$payload"
}
# ── Query Cost Management API ────────────────────────────────────────
query_costs() {
local start="$1" end="$2"
local payload
payload="$(build_query_payload "$start" "$end")"
local scope="/subscriptions/${SUBSCRIPTION}"
local api_url="https://management.azure.com${scope}/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
log_debug "Querying: $api_url"
log_debug "Payload: $payload"
az rest \
--method post \
--url "$api_url" \
--body "$payload" \
--output json 2>/dev/null
}
# ── Parse cost data ──────────────────────────────────────────────────
parse_costs() {
local raw="$1"
echo "$raw" | jq -r '
.properties.rows // [] |
map({
key: .[1],
amount: (.[0] | tonumber)
}) |
group_by(.key) |
map({
key: .[0].key,
total: (map(.amount) | add)
}) |
sort_by(-.total) |
.[] |
"\(.key)\t\(.total)"
' 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 sub_name
sub_name=$(az account show --query 'name' -o tsv 2>/dev/null || echo "unknown")
echo "Azure Cost Reporter"
echo "Subscription: $sub_name ($SUBSCRIPTION)"
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
RESOURCE_GROUP) label="RESOURCE_GROUP" ;;
TAG) label="TAG" ;;
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
RESOURCE_GROUP) label="resource_group" ;;
TAG) label="tag" ;;
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
RESOURCE_GROUP) label="resource_group" ;;
TAG) label="tag" ;;
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"
# Parse into associative arrays
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")"
# Ensure previous-only keys appear in current with 0
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..."
# Truncate for Slack message limits
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]
Modes:
--daily Yesterday vs day before cost breakdown
--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), RESOURCE_GROUP, TAG
--tag KEY=VALUE Filter by cost allocation tag
--subscription SUB Azure subscription ID (default: current az account)
--format FORMAT text (default), csv, json
--slack WEBHOOK_URL Post report to Slack webhook
--verbose Debug output
--no-color Disable colored output
--help Show this help
Environment:
SUBSCRIPTION Default subscription ID
SLACK_WEBHOOK_URL Default Slack webhook URL
COLOR auto (default), always, never
Notes:
Email delivery is not built in. Pipe output to sendmail or use an
SMTP relay: ./azure-cost-reporter.sh --daily | mail -s "Azure Costs" user@example.com
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 ;;
--tag)
[[ $# -lt 2 ]] && die "--tag requires KEY=VALUE"
[[ "$2" != *"="* ]] && die "--tag value must be KEY=VALUE"
COST_TAG_KEY="${2%%=*}"; COST_TAG_VALUE="${2#*=}"; shift 2 ;;
--subscription)
[[ $# -lt 2 ]] && die "--subscription requires a value"
SUBSCRIPTION="$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 ;;
--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 "$SLACK_URL" && -n "$SLACK_WEBHOOK_URL" ]] && SLACK_URL="$SLACK_WEBHOOK_URL"
case "$GROUP_BY" in
SERVICE|RESOURCE_GROUP|TAG) ;;
*) die "Invalid --group-by: $GROUP_BY" ;;
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)
# Validate Azure credentials
log_debug "Validating Azure credentials..."
az account show >/dev/null 2>&1 \
|| die "Azure credentials not configured — run 'az login' first"
resolve_subscription
compute_ranges
log_info "Querying Cost Management ($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" ]]; then
die "No cost data returned for $PERIOD_START$PERIOD_END"
fi
local report
report="$(render_report "$curr_raw" "$prev_raw")"
# Output to stdout
echo "$report"
# Slack delivery
if [[ -n "$SLACK_URL" ]]; then
send_slack "$report" "$SLACK_URL"
fi
local elapsed=$(( $(date +%s) - START_TIME ))
log_info "Completed in ${elapsed}s"
}
main "$@"