├── .gitignore ├── Makefile ├── README.md ├── az-consumption-report.sh ├── az-consumption-summary.sh ├── az_consumption_summary.py ├── requirements.txt └── sample.png /.gitignore: -------------------------------------------------------------------------------- 1 | requirements.dev.txt 2 | az_consumption_usage_list.json 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | CURRENT_DIR=$(shell pwd) 2 | OUTPUT_DIR=~/.local/bin 3 | OUTPUT_BIN=$(OUTPUT_DIR)/az-consumption-summary 4 | REPORT_BIN=$(OUTPUT_DIR)/az-consumption-report 5 | 6 | .PHONY: install install-report clean 7 | 8 | install: 9 | if [ ! -d "$(CURRENT_DIR)/venv" ]; then \ 10 | python3 -m venv venv; \ 11 | . venv/bin/activate; \ 12 | pip install -r requirements.txt; \ 13 | fi; \ 14 | sed 's|SOURCE_DIR|$(CURRENT_DIR)|g' ./az-consumption-summary.sh > $(OUTPUT_BIN) 15 | chmod 755 $(OUTPUT_BIN) 16 | 17 | install-report: install 18 | cp az-consumption-report.sh $(REPORT_BIN) 19 | 20 | clean: 21 | if [ -f "$(OUTPUT_BIN)" ]; then rm $(OUTPUT_BIN); fi 22 | if [ -f "$(REPORT_BIN)" ]; then rm $(REPORT_BIN); fi 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # az-consumption-summary 2 | 3 | Show a consumption report in your terminal for your Azure subscription. 4 | 5 | ![Sample](./sample.png) 6 | 7 | ## Requirements 8 | 9 | * Python 3 10 | * Bash 11 | * Azure CLI 12 | * [termgraph](https://github.com/mkaz/termgraph) 13 | * make 14 | 15 | ## Installation 16 | 17 | Clone this repo and then run `make install-report`. 18 | 19 | ## Usage 20 | 21 | The main report can be displayed in your terminal by running: 22 | 23 | ``` 24 | $ az-consumption-report 25 | ``` 26 | 27 | **Note: This uses the currently logged in Azure CLI credentials and subscription.** 28 | 29 | All this script does is run `az consumption usage list` and then pipes that to `az-consumption-summary`: 30 | 31 | ``` 32 | $ az-consumption-summary --help 33 | Usage: az_consumption_summary.py [OPTIONS] COMMAND [ARGS]... 34 | 35 | `az consumption usage list` summarizer 36 | 37 | Options: 38 | --help Show this message and exit. 39 | 40 | Commands: 41 | costs Cost summary grouping from consumption 42 | timeline Billing period and timing summary 43 | total Get the total cost of all input data 44 | ``` 45 | 46 | The output of these three commands is formatted explicitly for termgraph. 47 | 48 | ## Windows support 49 | 50 | This was developed and tested on Linux (it should work with macOS, but please open an issue if that isn't the case). If you are interested in running this on Windows then you should be able to in WSL. 51 | -------------------------------------------------------------------------------- /az-consumption-report.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SCRIPT_INPUT=$(az consumption usage list -o json) 4 | 5 | BOLD=$(tput bold) 6 | RESET=$(tput sgr0) 7 | 8 | echo 9 | echo "Subscription: ${BOLD}$(az account show --query name -o tsv)${RESET}" 10 | echo 11 | echo "$SCRIPT_INPUT" | az-consumption-summary timeline 12 | echo 13 | echo "${BOLD}Total: $(echo "$SCRIPT_INPUT" | az-consumption-summary total)${RESET}" 14 | echo "$SCRIPT_INPUT" | az-consumption-summary costs \ 15 | --group-by resource-group | termgraph --width 20 --color yellow 16 | echo "$SCRIPT_INPUT" | az-consumption-summary costs \ 17 | --group-by type | termgraph --width 20 --color cyan 18 | -------------------------------------------------------------------------------- /az-consumption-summary.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source "SOURCE_DIR/venv/bin/activate" 4 | python "SOURCE_DIR/az_consumption_summary.py" "$@" 5 | deactivate 6 | -------------------------------------------------------------------------------- /az_consumption_summary.py: -------------------------------------------------------------------------------- 1 | """az consumption summary""" 2 | 3 | from datetime import datetime 4 | import itertools 5 | import json 6 | from pathlib import Path 7 | import sys 8 | from typing import Dict, Any 9 | import click 10 | 11 | @click.group() 12 | def entry_point(): 13 | """`az consumption usage list` summarizer""" 14 | # pylint:disable=unnecessary-pass 15 | pass 16 | 17 | @click.command() 18 | @click.option( 19 | "--input-file", 20 | help="optional input json file") 21 | @click.option( 22 | "--group-by", 23 | default="resource-group", 24 | help="group by 'resource-group' or 'type' (default 'resource-group')") 25 | def costs( 26 | input_file: str = "", 27 | group_by: str = "resource-group") -> None: 28 | """Cost summary grouping from consumption""" 29 | 30 | consumption_data = _consumption_data_from_input(input_file=input_file) 31 | consumption_data = sorted(consumption_data, key=lambda x: x["instanceId"]) 32 | 33 | instance_summary = [] 34 | 35 | for key, group in itertools.groupby(consumption_data, key=lambda x: x["instanceId"]): 36 | cost = 0.0 37 | for group_item in group: 38 | cost += float(group_item["pretaxCost"]) 39 | instance_summary.append(dict( 40 | instance_id=key, 41 | cost=cost, 42 | resource_group=_resource_group_from_instance_id(instance_id=key), 43 | resource_type=_resource_type_from_instance_id(instance_id=key) 44 | )) 45 | 46 | if group_by == "resource-group": 47 | group_by_func = lambda x: x["resource_group"] 48 | elif group_by == "type": 49 | group_by_func = lambda x: x["resource_type"] 50 | else: 51 | raise Exception("Unknown group by type") 52 | 53 | consumption_summary = [] 54 | instance_summary = sorted(instance_summary, key=group_by_func) 55 | for key, group in itertools.groupby(instance_summary, key=group_by_func): 56 | cost = 0.0 57 | for group_item in group: 58 | cost += group_item["cost"] 59 | consumption_summary.append(dict( 60 | key=key, 61 | cost=cost 62 | )) 63 | 64 | consumption_summary = sorted(consumption_summary, key=lambda x: -x["cost"]) 65 | 66 | for consumption_item in consumption_summary: 67 | print("{},{:,.2f}".format(consumption_item["key"], consumption_item["cost"])) 68 | 69 | @click.command() 70 | @click.option( 71 | "--input-file", 72 | help="optional input json file") 73 | def timeline(input_file: str = "") -> None: 74 | """Billing period and timing summary""" 75 | 76 | consumption_data = _consumption_data_from_input(input_file=input_file) 77 | consumption_data = sorted(consumption_data, key=lambda x: x["billingPeriodId"]) 78 | billing_periods = [] 79 | for key, _ in itertools.groupby(consumption_data, key=lambda x: x["billingPeriodId"]): 80 | billing_periods.append(key.split("/")[-1]) 81 | 82 | print(f"Billing period(s): {','.join(billing_periods)}") 83 | 84 | usage_start = None 85 | usage_end = None 86 | datetime_format = "%Y-%m-%dT%H:%M:%SZ" 87 | for data_item in consumption_data: 88 | usage_start_converted = datetime.strptime(data_item["usageStart"], datetime_format) 89 | usage_end_converted = datetime.strptime(data_item["usageEnd"], datetime_format) 90 | if not usage_start or usage_start_converted < usage_start: 91 | usage_start = usage_start_converted 92 | if not usage_end or usage_end_converted > usage_end: 93 | usage_end = usage_end_converted 94 | 95 | print(f"{usage_start} -> {usage_end}") 96 | 97 | @click.command() 98 | @click.option( 99 | "--input-file", 100 | help="optional input json file") 101 | def total(input_file: str = "") -> None: 102 | """Get the total cost of all input data""" 103 | 104 | consumption_data = _consumption_data_from_input(input_file=input_file) 105 | cost = 0.0 106 | for data_item in consumption_data: 107 | cost += float(data_item["pretaxCost"]) 108 | 109 | print("${:,.2f}".format(cost)) 110 | 111 | def _resource_group_from_instance_id(instance_id: str) -> str: 112 | """Parse the resource group from the instance ID""" 113 | 114 | instance_id_parts = instance_id.split("/") 115 | resource_group = "none" 116 | for idx, part in enumerate(instance_id_parts): 117 | if part == "resourceGroups": 118 | resource_group = instance_id_parts[idx + 1].lower() 119 | 120 | return resource_group 121 | 122 | def _resource_type_from_instance_id(instance_id: str) -> str: 123 | """Parse the resource type from the instance ID""" 124 | 125 | return instance_id.split("/")[-2] 126 | 127 | def _consumption_data_from_input(input_file: str) -> Dict[str, Any]: 128 | """Retrieve input data from either a file or stdin""" 129 | 130 | if not input_file and sys.stdin.isatty(): 131 | print("You must either pass --input-file or stdin") 132 | sys.exit(1) 133 | 134 | if not sys.stdin.isatty(): 135 | consumption_input = "".join(sys.stdin) 136 | else: 137 | with open(Path(input_file).resolve()) as input_file_handle: 138 | consumption_input = "".join(input_file_handle.readlines()) 139 | 140 | return json.loads(consumption_input) 141 | 142 | entry_point.add_command(costs) 143 | entry_point.add_command(timeline) 144 | entry_point.add_command(total) 145 | 146 | if __name__ == "__main__": 147 | entry_point() 148 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click==7.1.2 2 | -------------------------------------------------------------------------------- /sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trstringer/az-consumption-summary/51d4e2f222a6d4126eba3e00f5138987bb531bbd/sample.png --------------------------------------------------------------------------------