aztree 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aztree/__init__.py +805 -0
- aztree/__main__.py +4 -0
- aztree/viewer.html +629 -0
- aztree-0.2.0.dist-info/METADATA +127 -0
- aztree-0.2.0.dist-info/RECORD +8 -0
- aztree-0.2.0.dist-info/WHEEL +4 -0
- aztree-0.2.0.dist-info/entry_points.txt +2 -0
- aztree-0.2.0.dist-info/licenses/LICENSE +22 -0
aztree/__init__.py
ADDED
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
"""aztree: see where your Azure money goes, as a disktree-style treemap.
|
|
2
|
+
|
|
3
|
+
aztree # read your Azure costs (last 30 days) and open the map
|
|
4
|
+
aztree --demo # fake data, no Azure needed
|
|
5
|
+
python -m aztree # the same, from a clone without installing
|
|
6
|
+
|
|
7
|
+
Needs a logged-in Azure CLI (`az login`), or a token in AZURE_ACCESS_TOKEN. No other dependencies.
|
|
8
|
+
"""
|
|
9
|
+
__version__ = "0.2.0"
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import datetime as dt
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import random
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
import webbrowser
|
|
25
|
+
from collections import Counter
|
|
26
|
+
from importlib import resources
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
def home():
|
|
30
|
+
"""Where runs keep the page and the data: ~/.aztree/ (or AZTREE_HOME), never inside a repo by accident.
|
|
31
|
+
The data holds subscription IDs and costs."""
|
|
32
|
+
return Path(os.environ.get("AZTREE_HOME") or Path.home() / ".aztree")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def template():
|
|
36
|
+
"""The page, shipped inside the package (works from a clone, a wheel or a zip)."""
|
|
37
|
+
return resources.files(__name__).joinpath("viewer.html").read_text(encoding="utf-8")
|
|
38
|
+
|
|
39
|
+
ARM = "https://management.azure.com"
|
|
40
|
+
API_VERSION = "2025-03-01" # Microsoft.CostManagement/query
|
|
41
|
+
MAX_TRIES = 8 # per request, when Cost Management throttles us
|
|
42
|
+
MAX_RESOURCE_PAGES = 10 # past this, the resource view reads one total per resource and period instead of daily rows
|
|
43
|
+
# What to sum, in order of preference. Some scopes reject USD columns, and older offers only know PreTaxCost.
|
|
44
|
+
AGGREGATIONS = [["Cost", "CostUSD"], ["Cost"], ["PreTaxCost", "PreTaxCostUSD"], ["PreTaxCost"]]
|
|
45
|
+
# A 400 about those columns names the aggregation or a column. "Cost" alone isn't enough: "Cost Management is not
|
|
46
|
+
# supported for this offer" and "not registered for Microsoft.CostManagement" are not about columns.
|
|
47
|
+
COLUMN_ERROR = re.compile(r"aggregation|column|costusd|pretaxcost|\bcost\b(?!\s*management)", re.I)
|
|
48
|
+
|
|
49
|
+
# Each view is outer box -> inner box. A query can group by two dimensions at most, so the
|
|
50
|
+
# subscription view is not queried: every query runs per subscription and call 1 feeds it too.
|
|
51
|
+
VIEWS = {
|
|
52
|
+
"service": ["ServiceName", "Meter"],
|
|
53
|
+
"subscription": ["SubscriptionId", "ServiceName"],
|
|
54
|
+
"region": ["ResourceLocation", "ServiceName"],
|
|
55
|
+
"resource": ["ResourceGroupName", "ResourceId"],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def die(msg):
|
|
60
|
+
print(f"\naztree: {msg}", file=sys.stderr)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------- auth and subscriptions
|
|
65
|
+
|
|
66
|
+
def az_cli(args, run=subprocess.run, az_path=None):
|
|
67
|
+
"""Run the Azure CLI and return stdout. `az` is az.cmd on Windows, so resolve the full path first."""
|
|
68
|
+
path = az_path or shutil.which("az")
|
|
69
|
+
if not path:
|
|
70
|
+
die("need the Azure CLI (https://aka.ms/azcli), or a token in AZURE_ACCESS_TOKEN.")
|
|
71
|
+
r = run([path, *args], capture_output=True, text=True)
|
|
72
|
+
if r.returncode:
|
|
73
|
+
err = r.stderr.strip()
|
|
74
|
+
hint = "\n -> Log in first: `az login`." if "login" in err.lower() or "expired" in err.lower() else ""
|
|
75
|
+
die(f"Azure CLI error: {err}{hint}")
|
|
76
|
+
return r.stdout
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_token(env=os.environ, run=subprocess.run, az_path=None, tenant=None):
|
|
80
|
+
"""A token for ARM. Tokens are per tenant, so ask for the tenant a subscription lives in."""
|
|
81
|
+
if env.get("AZURE_ACCESS_TOKEN"):
|
|
82
|
+
return env["AZURE_ACCESS_TOKEN"]
|
|
83
|
+
args = ["account", "get-access-token", "--resource", ARM + "/", "--query", "accessToken", "-o", "tsv"]
|
|
84
|
+
return az_cli(args + (["--tenant", tenant] if tenant else []), run=run, az_path=az_path).strip()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cli_subscriptions(run=subprocess.run, az_path=None):
|
|
88
|
+
"""Every subscription the Azure CLI knows, across every tenant you're logged in to."""
|
|
89
|
+
subs = json.loads(az_cli(["account", "list", "-o", "json"], run=run, az_path=az_path))
|
|
90
|
+
return [{"id": s["id"], "name": s["name"], "state": s["state"], "tenant": s.get("tenantId")} for s in subs]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def list_subscriptions(az):
|
|
94
|
+
"""Every subscription a bare token can see, straight from ARM: only that token's tenant."""
|
|
95
|
+
subs, url = [], "/subscriptions?api-version=2022-12-01"
|
|
96
|
+
while url:
|
|
97
|
+
page = az.call("GET", url)
|
|
98
|
+
subs += [{"id": s["subscriptionId"], "name": s["displayName"], "state": s["state"], "tenant": s.get("tenantId")}
|
|
99
|
+
for s in page.get("value", [])]
|
|
100
|
+
url = page.get("nextLink")
|
|
101
|
+
return subs
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def current_subscription(run=subprocess.run):
|
|
105
|
+
"""The subscription `az account show` points at, or None without the CLI."""
|
|
106
|
+
if not shutil.which("az"):
|
|
107
|
+
return None
|
|
108
|
+
return json.loads(az_cli(["account", "show", "--query", "{id:id, name:name, tenant:tenantId}", "-o", "json"], run=run))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def sub_ref(s):
|
|
112
|
+
return {"id": s["id"], "name": s["name"], "tenant": s.get("tenant")}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def pick_subscriptions(available, wanted, all_, current):
|
|
116
|
+
"""Choose which subscriptions to read: --all, --subscription (id or name), or the CLI's current one."""
|
|
117
|
+
if all_:
|
|
118
|
+
return [sub_ref(s) for s in available if s["state"] == "Enabled"]
|
|
119
|
+
if not wanted:
|
|
120
|
+
if not current:
|
|
121
|
+
die("no default subscription. Pass --subscription ID_OR_NAME or --all.")
|
|
122
|
+
return [sub_ref(current)]
|
|
123
|
+
picked = []
|
|
124
|
+
for w in wanted:
|
|
125
|
+
hits = [s for s in available if w.lower() in (s["id"].lower(), s["name"].lower())]
|
|
126
|
+
if not hits:
|
|
127
|
+
known = "\n ".join(f"{s['name']} ({s['id']})" for s in available) or "(none)"
|
|
128
|
+
die(f"no subscription matches '{w}'. Subscriptions you can read:\n {known}")
|
|
129
|
+
if len(hits) > 1:
|
|
130
|
+
ids = "\n ".join(s["id"] for s in hits)
|
|
131
|
+
die(f"{len(hits)} subscriptions are named '{w}'. Pass the one you mean by id:\n {ids}")
|
|
132
|
+
hit = hits[0]
|
|
133
|
+
if all(p["id"] != hit["id"] for p in picked):
|
|
134
|
+
picked.append(sub_ref(hit))
|
|
135
|
+
return picked
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------- Azure REST client
|
|
139
|
+
|
|
140
|
+
class AzureError(Exception):
|
|
141
|
+
def __init__(self, status, message):
|
|
142
|
+
super().__init__(f"HTTP {status}: {message}")
|
|
143
|
+
self.status = status
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TooManyPages(Exception):
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def http_send(method, url, data, headers):
|
|
151
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
152
|
+
try:
|
|
153
|
+
with urllib.request.urlopen(req, timeout=120) as r:
|
|
154
|
+
return r.status, dict(r.headers), r.read()
|
|
155
|
+
except urllib.error.HTTPError as e:
|
|
156
|
+
return e.code, dict(e.headers), e.read()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def retry_after(headers):
|
|
160
|
+
"""Cost Management throttles per scope, per client type and per QPU, each with its own
|
|
161
|
+
*-retry-after header. Wait for the longest one."""
|
|
162
|
+
waits = [int(v) for k, v in headers.items() if k.lower().endswith("retry-after") and str(v).strip().isdigit()]
|
|
163
|
+
return max(waits) if waits and max(waits) > 0 else 5
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Azure:
|
|
167
|
+
def __init__(self, token, send=http_send, sleep=time.sleep, verbose=False, log=print):
|
|
168
|
+
"""`token` is a token, or a function tenant -> token for runs that span tenants."""
|
|
169
|
+
self.token, self.send, self.sleep, self.verbose, self.log = token, send, sleep, verbose, log
|
|
170
|
+
self.tokens = {}
|
|
171
|
+
self.requests = 0
|
|
172
|
+
|
|
173
|
+
def token_for(self, tenant):
|
|
174
|
+
if isinstance(self.token, str):
|
|
175
|
+
return self.token
|
|
176
|
+
if tenant not in self.tokens:
|
|
177
|
+
self.tokens[tenant] = self.token(tenant)
|
|
178
|
+
return self.tokens[tenant]
|
|
179
|
+
|
|
180
|
+
def call(self, method, url, body=None, tenant=None):
|
|
181
|
+
url = url if url.startswith("https://") else ARM + url
|
|
182
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
183
|
+
headers = {"Authorization": "Bearer " + self.token_for(tenant), "Content-Type": "application/json"}
|
|
184
|
+
for attempt in range(1, MAX_TRIES + 1):
|
|
185
|
+
try:
|
|
186
|
+
status, resp_headers, raw = self.send(method, url, data, headers)
|
|
187
|
+
except OSError as e: # URLError, timeouts, resets: the network, not Azure, said no
|
|
188
|
+
reason = getattr(e, "reason", e)
|
|
189
|
+
if attempt == MAX_TRIES:
|
|
190
|
+
raise AzureError(0, f"network error: {reason}") from e
|
|
191
|
+
self.log(f" network error ({reason}); retrying in {5 * attempt}s ...")
|
|
192
|
+
self.sleep(5 * attempt)
|
|
193
|
+
continue
|
|
194
|
+
self.requests += 1
|
|
195
|
+
if self.verbose:
|
|
196
|
+
qpu = {k.lower(): v for k, v in resp_headers.items()}.get("x-ms-ratelimit-microsoft.costmanagement-qpu-consumed")
|
|
197
|
+
if qpu:
|
|
198
|
+
self.log(f" ({qpu} QPU consumed)")
|
|
199
|
+
if (status == 429 or status >= 500) and attempt < MAX_TRIES:
|
|
200
|
+
wait = retry_after(resp_headers)
|
|
201
|
+
self.log(f" Azure is throttling us (HTTP {status}); waiting {wait}s ...")
|
|
202
|
+
self.sleep(wait)
|
|
203
|
+
continue
|
|
204
|
+
if status >= 400:
|
|
205
|
+
raise AzureError(status, error_message(raw))
|
|
206
|
+
return json.loads(raw) if raw else {}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def error_message(raw):
|
|
210
|
+
try:
|
|
211
|
+
err = json.loads(raw)["error"]
|
|
212
|
+
return f"{err.get('code', '')}: {err.get('message', '')}".strip(": ")
|
|
213
|
+
except (ValueError, KeyError, TypeError):
|
|
214
|
+
return raw.decode(errors="replace")[:300] if isinstance(raw, bytes) else str(raw)[:300]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def explain(e):
|
|
218
|
+
hints = []
|
|
219
|
+
if e.status == 401:
|
|
220
|
+
hints.append("Your token is missing or expired. Run `az login` (or refresh AZURE_ACCESS_TOKEN).")
|
|
221
|
+
if e.status == 403:
|
|
222
|
+
hints.append("You need the Cost Management Reader (or Reader) role on the subscription or scope.")
|
|
223
|
+
if e.status == 429:
|
|
224
|
+
hints.append("Cost Management kept throttling. Wait a minute and try again, or read fewer subscriptions.")
|
|
225
|
+
if e.status == 0:
|
|
226
|
+
hints.append("Check your network connection and try again.")
|
|
227
|
+
return f"Azure error: {e}" + ("\n -> " + "\n -> ".join(hints) if hints else "")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def query(az, scope, start, end, groupings, metric, aggs=("Cost", "CostUSD"), max_pages=None, tenant=None,
|
|
231
|
+
granularity="Daily"):
|
|
232
|
+
"""One Cost Management query. `start` and `end` are ISO dates, both inclusive. With granularity=None, Azure
|
|
233
|
+
sums each group over the whole period: one row per group instead of one per group per day.
|
|
234
|
+
Returns every row of every page as a dict keyed by column name."""
|
|
235
|
+
dataset = {
|
|
236
|
+
"aggregation": {f"total{a}": {"name": a, "function": "Sum"} for a in aggs},
|
|
237
|
+
"grouping": [{"type": "Dimension", "name": g} for g in groupings],
|
|
238
|
+
}
|
|
239
|
+
if granularity:
|
|
240
|
+
dataset["granularity"] = granularity
|
|
241
|
+
body = {
|
|
242
|
+
"type": metric,
|
|
243
|
+
"timeframe": "Custom",
|
|
244
|
+
"timePeriod": {"from": f"{start}T00:00:00Z", "to": f"{end}T23:59:59Z"},
|
|
245
|
+
"dataset": dataset,
|
|
246
|
+
}
|
|
247
|
+
url = f"{scope}/providers/Microsoft.CostManagement/query?api-version={API_VERSION}"
|
|
248
|
+
rows, pages = [], 0
|
|
249
|
+
while url:
|
|
250
|
+
pages += 1
|
|
251
|
+
if max_pages and pages > max_pages:
|
|
252
|
+
raise TooManyPages(f"more than {max_pages} pages")
|
|
253
|
+
props = az.call("POST", url, body, tenant=tenant).get("properties", {})
|
|
254
|
+
cols = [c["name"] for c in props.get("columns", [])]
|
|
255
|
+
if cols and aggs[0] not in cols:
|
|
256
|
+
raise AzureError(200, f"Cost Management answered without a {aggs[0]} column (columns: {', '.join(cols)})")
|
|
257
|
+
rows += [dict(zip(cols, r)) for r in props.get("rows", [])]
|
|
258
|
+
url = props.get("nextLink")
|
|
259
|
+
if getattr(az, "verbose", False):
|
|
260
|
+
az.log(f" ({pages} page{'s' if pages != 1 else ''}, {len(rows)} rows)")
|
|
261
|
+
return rows
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------- reading costs
|
|
265
|
+
|
|
266
|
+
def subscription_target(sub):
|
|
267
|
+
return {"id": sub["id"], "name": sub["name"], "scope": f"/subscriptions/{sub['id']}", "tenant": sub.get("tenant")}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def scope_target(scope):
|
|
271
|
+
scope = "/" + scope.strip("/")
|
|
272
|
+
return {"id": scope, "name": scope, "scope": scope, "tenant": None}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def usage_day(v):
|
|
276
|
+
"""UsageDate arrives as 20260930 (a number) or as '2026-09-30T00:00:00'. Return 'yyyymmdd'."""
|
|
277
|
+
s = str(v)
|
|
278
|
+
return s[:10].replace("-", "") if "-" in s else s[:8]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def group_key(target, rg, resource_id):
|
|
282
|
+
"""The resource group's ARM id, or the subscription's for resources outside any group.
|
|
283
|
+
Keying on the id keeps two `rg-app` groups in different subscriptions apart."""
|
|
284
|
+
m = re.match(r"/subscriptions/[^/]+", resource_id or "", re.I)
|
|
285
|
+
sub = m.group(0).lower() if m else target["scope"].lower()
|
|
286
|
+
return f"{sub}/resourcegroups/{rg.lower()}" if rg else sub
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def last_full_day(today=None):
|
|
290
|
+
"""The day before yesterday. Azure takes 8-24 hours to post usage, so yesterday is still filling in
|
|
291
|
+
and would drag down every comparison, grower and pace figure."""
|
|
292
|
+
return (today or dt.date.today()) - dt.timedelta(2)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def fetch(az, targets, days, metric, advisor=True, log=print, today=None):
|
|
296
|
+
end = last_full_day(today)
|
|
297
|
+
start = end - dt.timedelta(2 * days - 1) # current window + previous window, for the "vs prev" deltas
|
|
298
|
+
dates = [(start + dt.timedelta(i)).isoformat() for i in range(2 * days)]
|
|
299
|
+
index = {d.replace("-", ""): i for i, d in enumerate(dates)}
|
|
300
|
+
raw = {v: [] for v in VIEWS} # (key, day index, cost, cost in USD), folded once the currency is known
|
|
301
|
+
names = {v: {} for v in VIEWS}
|
|
302
|
+
columns = {} # target id -> cost columns that target accepts, best first; every subscription starts at the top
|
|
303
|
+
subs, fallback = [], []
|
|
304
|
+
twins = Counter(t["name"] for t in targets) # "Pay-As-You-Go" twice needs the id to tell them apart
|
|
305
|
+
targets = [{**t, "name": f"{t['name']} ({t['id'][:8]})"} if twins[t["name"]] > 1 else t for t in targets]
|
|
306
|
+
|
|
307
|
+
def run(target, groupings, start=None, end=None, **kw):
|
|
308
|
+
aggs = columns.setdefault(target["id"], list(AGGREGATIONS))
|
|
309
|
+
while True:
|
|
310
|
+
try:
|
|
311
|
+
return query(az, target["scope"], start or dates[0], end or dates[-1], groupings, metric, aggs[0],
|
|
312
|
+
tenant=target.get("tenant"), **kw)
|
|
313
|
+
except AzureError as e:
|
|
314
|
+
# step down only when Azure objects to the cost columns; any other 400 is a real error
|
|
315
|
+
about_columns = COLUMN_ERROR.search(str(e))
|
|
316
|
+
if e.status != 400 or len(aggs) == 1 or not about_columns:
|
|
317
|
+
raise
|
|
318
|
+
aggs.pop(0)
|
|
319
|
+
|
|
320
|
+
def add(view, key, r, day=None):
|
|
321
|
+
"""`day` is set for period totals, which have no UsageDate: they land on their period's first day."""
|
|
322
|
+
i = day if day is not None else index.get(usage_day(r.get("UsageDate")))
|
|
323
|
+
if i is not None:
|
|
324
|
+
cost, cost_usd = r.get("Cost", r.get("PreTaxCost")), r.get("CostUSD", r.get("PreTaxCostUSD"))
|
|
325
|
+
raw[view].append((key, i, cost or 0.0, cost_usd))
|
|
326
|
+
|
|
327
|
+
for t in targets:
|
|
328
|
+
log(f" {t['name']}: services ...")
|
|
329
|
+
currencies = Counter()
|
|
330
|
+
for r in run(t, VIEWS["service"]):
|
|
331
|
+
currencies[r.get("Currency")] += 1
|
|
332
|
+
add("service", (service_of(r), r.get("Meter") or "(no meter)"), r)
|
|
333
|
+
add("subscription", (t["id"], service_of(r)), r)
|
|
334
|
+
names["subscription"][t["id"]] = t["name"]
|
|
335
|
+
|
|
336
|
+
log(f" {t['name']}: resources ...")
|
|
337
|
+
try:
|
|
338
|
+
rows = [(r, None) for r in run(t, VIEWS["resource"], max_pages=MAX_RESOURCE_PAGES)]
|
|
339
|
+
except TooManyPages:
|
|
340
|
+
# too many resources x days: read one total per resource and period instead (two small queries)
|
|
341
|
+
log(f" {t['name']}: too many resources for daily detail, reading period totals instead")
|
|
342
|
+
prev = run(t, VIEWS["resource"], start=dates[0], end=dates[days - 1], granularity=None)
|
|
343
|
+
cur = run(t, VIEWS["resource"], start=dates[days], end=dates[-1], granularity=None)
|
|
344
|
+
rows = [(r, 0) for r in prev] + [(r, days) for r in cur]
|
|
345
|
+
fallback.append(t["name"])
|
|
346
|
+
for r, day in rows:
|
|
347
|
+
rg, rid = r.get("ResourceGroupName") or "", (r.get("ResourceId") or "").lower()
|
|
348
|
+
key = group_key(t, rg, rid)
|
|
349
|
+
label = rg or "(no resource group)"
|
|
350
|
+
names["resource"][key] = f"{label} · {t['name']}" if len(targets) > 1 else label
|
|
351
|
+
add("resource", (key, rid or "(no resource)"), r, day)
|
|
352
|
+
|
|
353
|
+
log(f" {t['name']}: regions ...")
|
|
354
|
+
for r in run(t, VIEWS["region"]):
|
|
355
|
+
add("region", ((r.get("ResourceLocation") or "").lower(), service_of(r)), r)
|
|
356
|
+
subs.append({"id": t["id"], "name": t["name"], "currency": next((c for c, _ in currencies.most_common() if c), None)})
|
|
357
|
+
|
|
358
|
+
recs, advisor_error = None, None
|
|
359
|
+
with_advisor = [t for t in targets if t["scope"].lower().startswith("/subscriptions/")] # Advisor is per subscription
|
|
360
|
+
if advisor and with_advisor:
|
|
361
|
+
recs = []
|
|
362
|
+
for t in with_advisor:
|
|
363
|
+
log(f" {t['name']}: Advisor ...")
|
|
364
|
+
try:
|
|
365
|
+
recs += advisor_recs(az, t)
|
|
366
|
+
except Exception as e: # Advisor needs Reader; cost data alone is still worth a page
|
|
367
|
+
# keep only the status: Azure's 403 text names the caller, and this lands in the AI export
|
|
368
|
+
advisor_error = f"HTTP {e.status}" if isinstance(e, AzureError) else type(e).__name__
|
|
369
|
+
log(f" {t['name']}: skipped Advisor ({e}). Reader on the subscription fixes access errors; --no-advisor hides this.")
|
|
370
|
+
recs.sort(key=lambda r: -(r["annual_savings"] or -1))
|
|
371
|
+
|
|
372
|
+
found = {s["currency"] for s in subs if s["currency"]}
|
|
373
|
+
# USD only if every row has a USD figure: a subscription may have answered in its billing currency alone
|
|
374
|
+
usd = len(found) > 1 and all(cost_usd is not None for entries in raw.values() for *_, cost_usd in entries)
|
|
375
|
+
if len(found) > 1 and not usd:
|
|
376
|
+
log(" warning: these subscriptions bill in different currencies and Azure won't convert them; totals mix currencies")
|
|
377
|
+
log(f" done: {az.requests} requests (Cost Management queries are free)")
|
|
378
|
+
return {
|
|
379
|
+
"days": dates, "split": days,
|
|
380
|
+
"views": {v: {"dims": dims, "names": names[v], "rows": fold(raw[v], len(dates), usd)} for v, dims in VIEWS.items()},
|
|
381
|
+
"currency": "USD" if usd or not found else min(found),
|
|
382
|
+
"subscriptions": subs, "resource_fallback": fallback,
|
|
383
|
+
"advisor": recs, "advisor_error": advisor_error, "demo": False,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def service_of(row):
|
|
388
|
+
return row.get("ServiceName") or "(no service)"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def fold(entries, n, usd):
|
|
392
|
+
"""(key, day, cost, cost_usd) entries -> packed rows of daily totals, in one currency."""
|
|
393
|
+
rows = {}
|
|
394
|
+
for key, i, cost, cost_usd in entries:
|
|
395
|
+
amount = (cost_usd or 0.0) if usd else cost
|
|
396
|
+
if amount:
|
|
397
|
+
rows.setdefault(key, [0.0] * n)[i] += amount
|
|
398
|
+
return pack(rows)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def pack(rows):
|
|
402
|
+
out = [{"k": list(k), "d": [round(v, 4) for v in d]} for k, d in rows.items()]
|
|
403
|
+
# drop only noise: a charge in one period and its refund in the other sum to zero but still count in both
|
|
404
|
+
return [r for r in out if sum(abs(v) for v in r["d"]) >= 0.005]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ---------------------------------------------------------------- demo data
|
|
408
|
+
|
|
409
|
+
P, S, D, X = (f"{c * 8}-{c * 4}-{c * 4}-{c * 4}-{c * 12}" for c in "1234")
|
|
410
|
+
DEMO_SUBS = {P: "acme-prod", S: "acme-staging", D: "acme-data", X: "sandbox-dev"}
|
|
411
|
+
VM, WEB, SQL, STG = ("microsoft.compute/virtualmachines", "microsoft.web/serverfarms", "microsoft.sql/servers",
|
|
412
|
+
"microsoft.storage/storageaccounts")
|
|
413
|
+
|
|
414
|
+
# service, meter, $/day, growth over the 60 days, [(subscription, region, resource group, provider/type/name, share)]
|
|
415
|
+
DEMO = [
|
|
416
|
+
("Virtual Machines", "D4s v5", 58, 0.05, [(P, "us east", "rg-app-prod", f"{VM}/vm-app-01", .5),
|
|
417
|
+
(P, "us east", "rg-app-prod", f"{VM}/vm-app-02", .5)]),
|
|
418
|
+
("Virtual Machines", "D8s v5", 44, 0.1, [(P, "us east", "mc_rg-aks-prod_aks-prod_eastus",
|
|
419
|
+
"microsoft.compute/virtualmachinescalesets/aks-nodepool1-vmss", 1)]),
|
|
420
|
+
("Virtual Machines", "E8s v5", 38, -0.7, [(D, "eu west", "rg-etl", f"{VM}/vm-etl-worker", 1)]), # scaled down: a drop
|
|
421
|
+
("Virtual Machines", "NC6s v3", 22, 0.3, [(D, "us east", "rg-ml", f"{VM}/vm-gpu-train", 1)]),
|
|
422
|
+
("Virtual Machines", "D2 v2", 9, 0, [(S, "us east", "rg-legacy", f"{VM}/vm-legacy-ftp", 1)]),
|
|
423
|
+
("Azure Kubernetes Service", "Standard Uptime SLA", 2.4, 0, [(P, "us east", "rg-aks-prod",
|
|
424
|
+
"microsoft.containerservice/managedclusters/aks-prod", 1)]),
|
|
425
|
+
("SQL Database", "vCore", 41, 0, [(P, "us east", "rg-data-prod", f"{SQL}/sql-prod/databases/orders", .7),
|
|
426
|
+
(S, "us east", "rg-data-staging", f"{SQL}/sql-staging/databases/orders", .3)]),
|
|
427
|
+
("SQL Database", "RA-GRS Data Stored", 6, 0.03, [(P, "us east", "rg-data-prod", f"{SQL}/sql-prod/databases/orders", 1)]),
|
|
428
|
+
("Azure Cosmos DB", "100 RU/s", 19, 1.2, [(P, "us east", "rg-data-prod",
|
|
429
|
+
"microsoft.documentdb/databaseaccounts/cosmos-catalog", 1)]),
|
|
430
|
+
("Azure Cosmos DB", "Data Stored", 3, 0.05, [(P, "us east", "rg-data-prod",
|
|
431
|
+
"microsoft.documentdb/databaseaccounts/cosmos-catalog", 1)]),
|
|
432
|
+
("Redis Cache", "C1 Cache Instance", 3.3, 0, [(P, "us east", "rg-app-prod", "microsoft.cache/redis/redis-sessions", 1)]),
|
|
433
|
+
("Storage", "Hot LRS Data Stored", 16, 0.04, [(D, "eu west", "rg-lake", f"{STG}/stlakeraw", .8),
|
|
434
|
+
(P, "us east", "rg-app-prod", f"{STG}/stappassets", .2)]),
|
|
435
|
+
("Storage", "Hot LRS Write Operations", 3, 0.1, [(D, "eu west", "rg-lake", f"{STG}/stlakeraw", 1)]),
|
|
436
|
+
("Storage", "P30 LRS Disk", 11, 0, [(P, "us east", "rg-app-prod", "microsoft.compute/disks/vm-app-01-data", 1)]),
|
|
437
|
+
("Storage", "LRS Snapshots", 7, 0.15, [(P, "us east", "rg-backup", "microsoft.compute/snapshots/snap-vm-app-01-2025", 1)]),
|
|
438
|
+
("Log Analytics", "Analytics Logs Data Ingestion", 34, 1.1, [
|
|
439
|
+
(P, "us east", "rg-monitoring", "microsoft.operationalinsights/workspaces/log-prod", .85),
|
|
440
|
+
(S, "us east", "rg-monitoring", "microsoft.operationalinsights/workspaces/log-staging", .15)]),
|
|
441
|
+
("Azure Monitor", "Standard Web Test Execution", 1.5, 0, [(P, "us east", "rg-monitoring",
|
|
442
|
+
"microsoft.insights/webtests/ping-home", 1)]),
|
|
443
|
+
("Microsoft Defender for Cloud", "Standard Node", 6, 0, [(P, "us east", "", "microsoft.security/pricings/virtualmachines", 1)]),
|
|
444
|
+
("Azure App Service", "P1 v3 App", 14, 0, [(P, "us east", "rg-app-prod", f"{WEB}/asp-web-prod", 1)]),
|
|
445
|
+
("Azure App Service", "P1 v2 App", 5, 0, [(S, "us east", "rg-app-staging", f"{WEB}/asp-web-staging", 1)]),
|
|
446
|
+
("Functions", "Premium vCPU Duration", 8, 0.1, [(P, "us east", "rg-app-prod", f"{WEB}/asp-func-prod", 1)]),
|
|
447
|
+
("Bandwidth", "Standard Data Transfer Out", 12, 0.1, [(P, "us east", "rg-app-prod", f"{VM}/vm-app-01", 1)]),
|
|
448
|
+
("Bandwidth", "Inter Continent Data Transfer Out - NAM or EU To Any", 4, 0.2, [(D, "eu west", "rg-etl", f"{VM}/vm-etl-worker", 1)]),
|
|
449
|
+
("NAT Gateway", "Standard Data Processed", 6, 0.3, [(P, "us east", "rg-network", "microsoft.network/natgateways/ng-prod", 1)]),
|
|
450
|
+
("NAT Gateway", "Standard Gateway", 1.1, 0, [(P, "us east", "rg-network", "microsoft.network/natgateways/ng-prod", 1)]),
|
|
451
|
+
("Virtual Network", "Standard Private Endpoint", 1.8, 0, [(P, "us east", "rg-network", "microsoft.network/privateendpoints/pe-sql-prod", 1)]),
|
|
452
|
+
("Virtual Network", "Standard IPv4 Static Public IP", 1.2, 0, [(P, "us east", "rg-network", "microsoft.network/publicipaddresses/pip-ng-prod", 1)]),
|
|
453
|
+
("Virtual Network", "Basic IPv4 Static Public IP", 1.4, 0, [(X, "us west 2", "rg-sandbox", "microsoft.network/publicipaddresses/pip-old-test", 1)]),
|
|
454
|
+
("Azure Front Door Service", "Standard Base Fees", 1.2, 0, [(P, "global", "rg-edge", "microsoft.cdn/profiles/afd-web", 1)]),
|
|
455
|
+
("Azure Front Door Service", "Standard Data Transfer Out", 4, 0.05, [(P, "global", "rg-edge", "microsoft.cdn/profiles/afd-web", 1)]),
|
|
456
|
+
("Foundry Models", "gpt 4.1 Inp glbl Tokens", 6, 3, [(X, "us east 2", "rg-ai-sandbox", "microsoft.cognitiveservices/accounts/oai-sandbox", 1)]),
|
|
457
|
+
("Foundry Models", "gpt 4.1 Outp glbl Tokens", 9, 3, [(X, "us east 2", "rg-ai-sandbox", "microsoft.cognitiveservices/accounts/oai-sandbox", 1)]),
|
|
458
|
+
("Azure Data Factory v2", "Cloud Data Movement", 5, 0.2, [(D, "eu west", "rg-etl", "microsoft.datafactory/factories/adf-etl", 1)]),
|
|
459
|
+
("Event Hubs", "Standard Throughput Unit", 3.6, 0, [(D, "eu west", "rg-etl", "microsoft.eventhub/namespaces/evh-ingest", 1)]),
|
|
460
|
+
("Container Registry", "Standard Registry Unit", 0.67, 0, [(P, "us east", "rg-app-prod", "microsoft.containerregistry/registries/acmecr", 1)]),
|
|
461
|
+
("Key Vault", "Operations", 0.3, 0, [(P, "us east", "rg-app-prod", "microsoft.keyvault/vaults/kv-app-prod", 1)]),
|
|
462
|
+
]
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def demo_rid(sub, rg, path):
|
|
466
|
+
return f"/subscriptions/{sub}/resourcegroups/{rg}/providers/{path}" if rg else f"/subscriptions/{sub}/providers/{path}"
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def demo_advisor():
|
|
470
|
+
def rec(problem, sub, path, savings, sku=None, term=None, rg=None):
|
|
471
|
+
resource = demo_rid(sub, rg, path) if path else f"/subscriptions/{sub}"
|
|
472
|
+
return {"problem": problem, "solution": problem, "impact": "High", "resource": resource,
|
|
473
|
+
"resource_name": resource.rsplit("/", 1)[-1] if path else DEMO_SUBS[sub], "resource_type": None,
|
|
474
|
+
"sku": sku, "term": term, "annual_savings": savings, "currency": "USD", "subscription": DEMO_SUBS[sub]}
|
|
475
|
+
|
|
476
|
+
return [
|
|
477
|
+
rec("Consider SQL PaaS DB reserved instance to save over the pay-as-you-go costs", P, None, 4380.0,
|
|
478
|
+
"SQL DB Single/Elastic Pool - General Purpose - Gen 5", "P3Y"),
|
|
479
|
+
rec("Consider purchasing a savings plan to unlock lower prices", P, None, 3120.0, "Compute_Savings_Plan", "P1Y"),
|
|
480
|
+
rec("Right-size or shutdown underutilized virtual machines", D, f"{VM}/vm-etl-worker", 2150.0, "Standard_E4s_v5", rg="rg-etl"),
|
|
481
|
+
rec("Consider Cosmos DB reserved instance to save over the pay-as-you-go costs", P, None, 1020.0, "100 RU/s", "P1Y"),
|
|
482
|
+
rec("Right-size or shutdown underutilized virtual machines", S, f"{VM}/vm-legacy-ftp", 640.0, "Standard_B2s", rg="rg-legacy"),
|
|
483
|
+
]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def demo(days, today=None):
|
|
487
|
+
rnd = random.Random(7)
|
|
488
|
+
end = last_full_day(today)
|
|
489
|
+
n = 2 * days
|
|
490
|
+
dates = [(end - dt.timedelta(n - 1 - i)).isoformat() for i in range(n)]
|
|
491
|
+
weekend = [dt.date.fromisoformat(d).weekday() >= 5 for d in dates]
|
|
492
|
+
rows = {v: {} for v in VIEWS}
|
|
493
|
+
names = {v: {} for v in VIEWS}
|
|
494
|
+
names["subscription"] = dict(DEMO_SUBS)
|
|
495
|
+
for service, meter, per_day, growth, spots in DEMO:
|
|
496
|
+
bursty = any(w in meter for w in ("Tokens", "Data Transfer", "Ingestion", "Operations", "Processed", "Duration"))
|
|
497
|
+
for sub, region, rg, path, share in spots:
|
|
498
|
+
daily = []
|
|
499
|
+
for i in range(n):
|
|
500
|
+
m = (1 + growth * i / (n - 1)) / (1 + growth / 2) # keep the average near per_day
|
|
501
|
+
m *= 0.8 if bursty and weekend[i] else 1
|
|
502
|
+
daily.append(max(0.0, per_day * share * m * rnd.gauss(1, 0.06)))
|
|
503
|
+
rid = demo_rid(sub, rg, path)
|
|
504
|
+
gkey = group_key({"scope": f"/subscriptions/{sub}"}, rg, rid)
|
|
505
|
+
names["resource"][gkey] = f"{rg or '(no resource group)'} · {DEMO_SUBS[sub]}"
|
|
506
|
+
for view, key in (("service", (service, meter)), ("subscription", (sub, service)),
|
|
507
|
+
("region", (region, service)), ("resource", (gkey, rid))):
|
|
508
|
+
acc = rows[view].setdefault(key, [0.0] * n)
|
|
509
|
+
for i, v in enumerate(daily):
|
|
510
|
+
acc[i] += v
|
|
511
|
+
# a cancelled Cosmos DB reservation refunded as one negative day: no box can show it, so the header notes it
|
|
512
|
+
rid = demo_rid(P, "rg-data-prod", "microsoft.documentdb/databaseaccounts/cosmos-catalog")
|
|
513
|
+
gkey = group_key({"scope": f"/subscriptions/{P}"}, "rg-data-prod", rid)
|
|
514
|
+
for view, key in (("service", ("Azure Cosmos DB", "Reserved 100 RU/s")), ("subscription", (P, "Azure Cosmos DB")),
|
|
515
|
+
("region", ("us east", "Azure Cosmos DB")), ("resource", (gkey, rid))):
|
|
516
|
+
rows[view].setdefault(key, [0.0] * n)[max(days, n - 6)] -= 150.0
|
|
517
|
+
return {
|
|
518
|
+
"days": dates, "split": days,
|
|
519
|
+
"views": {v: {"dims": VIEWS[v], "names": names[v], "rows": pack(rows[v])} for v in VIEWS},
|
|
520
|
+
"currency": "USD",
|
|
521
|
+
"subscriptions": [{"id": k, "name": v, "currency": "USD"} for k, v in DEMO_SUBS.items()],
|
|
522
|
+
"resource_fallback": [], "advisor": demo_advisor(), "advisor_error": None, "demo": True,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ---------------------------------------------------------------- worth a look
|
|
527
|
+
|
|
528
|
+
# Known money pits: (service pattern, meter pattern, why). First match wins. Shared with the viewer's
|
|
529
|
+
# "worth a look" panel, so the patterns must mean the same in Python and JavaScript.
|
|
530
|
+
OLD_VM_SIZES = r"^(?:(?:Basic[ ._])?A\d+m?(?: v2)?|DS?\d+(?: v2)?|F\d+s?)(?:$|/| Low Priority| Spot)"
|
|
531
|
+
PITS = [
|
|
532
|
+
(r"^(?:Log Analytics|Azure Monitor)$", r"Data Ingestion",
|
|
533
|
+
"Log Analytics ingestion — trim noisy tables, use Basic logs, or a commitment tier past 100 GB/day"),
|
|
534
|
+
(r"^Bandwidth$", r"Data Transfer Out", "data transfer out — keep traffic in one region, cache at the edge"),
|
|
535
|
+
(r"^NAT Gateway$", r"Data Processed", "NAT data processing — service or private endpoints for Storage, SQL and ACR skip it"),
|
|
536
|
+
(r"^Azure Firewall$", r"Data Processed|Premium", "Azure Firewall — processing and Premium add up; route only what needs inspection"),
|
|
537
|
+
(r"^Virtual Network$", r"^Basic .*Public IP", "Basic public IPs — the Basic SKU retired on 30 Sep 2025, move to Standard"),
|
|
538
|
+
(r"^Virtual Network$", r"Public IP|IP Address Hours", "public IPs — billed per hour each; release the ones nothing uses"),
|
|
539
|
+
(r"^Storage$", r"Snapshot", "disk snapshots — prune old ones; incremental snapshots on Standard storage cost less"),
|
|
540
|
+
(r"^Virtual Machines$", OLD_VM_SIZES, "previous-gen VM sizes — current generations cost less for the same work"),
|
|
541
|
+
(r"^Azure App Service$", r"^P\d+ ?v2 App", "Premium v2 App Service plans — Premium v3 gives more per dollar and can be reserved"),
|
|
542
|
+
(r"", r"Extended Security Update", "Extended Security Updates — upgrade the OS or SQL version to stop paying for them"),
|
|
543
|
+
]
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def pit(service, meter):
|
|
547
|
+
return next((why for s, m, why in PITS if re.search(s, service) and re.search(m, meter)), None)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def advisor_recs(az, target):
|
|
551
|
+
"""Azure Advisor's cost recommendations for one subscription, one per (kind, resource, SKU).
|
|
552
|
+
Advisor lists each reservation once per term and look-back period; keep the biggest saving."""
|
|
553
|
+
flt = urllib.parse.quote("Category eq 'Cost'")
|
|
554
|
+
url = f"{target['scope']}/providers/Microsoft.Advisor/recommendations?api-version=2023-01-01&$filter={flt}"
|
|
555
|
+
best = {}
|
|
556
|
+
while url:
|
|
557
|
+
page = az.call("GET", url, tenant=target.get("tenant"))
|
|
558
|
+
for item in page.get("value", []):
|
|
559
|
+
key, rec = advisor_rec(item.get("properties", {}), target)
|
|
560
|
+
if key not in best or (rec["annual_savings"] or 0) > (best[key]["annual_savings"] or 0):
|
|
561
|
+
best[key] = rec
|
|
562
|
+
url = page.get("nextLink")
|
|
563
|
+
return sorted(best.values(), key=lambda r: -(r["annual_savings"] or -1))
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def advisor_rec(p, target):
|
|
567
|
+
ext = p.get("extendedProperties") or {}
|
|
568
|
+
short = p.get("shortDescription") or {}
|
|
569
|
+
resource = ((p.get("resourceMetadata") or {}).get("resourceId") or "").lower()
|
|
570
|
+
savings = ext.get("annualSavingsAmount")
|
|
571
|
+
name = p.get("impactedValue") or resource.rsplit("/", 1)[-1]
|
|
572
|
+
rec = {
|
|
573
|
+
"problem": short.get("problem", ""),
|
|
574
|
+
"solution": short.get("solution", ""),
|
|
575
|
+
"impact": p.get("impact"),
|
|
576
|
+
"resource": resource,
|
|
577
|
+
"resource_name": target["name"] if name.lower() == target["id"].lower() else name,
|
|
578
|
+
"resource_type": p.get("impactedField"),
|
|
579
|
+
"sku": ext.get("displaySKU") or ext.get("sku") or ext.get("targetSku"),
|
|
580
|
+
"term": ext.get("term"),
|
|
581
|
+
"annual_savings": float(savings) if savings not in (None, "") else None,
|
|
582
|
+
"currency": ext.get("savingsCurrency"),
|
|
583
|
+
"subscription": target["name"],
|
|
584
|
+
}
|
|
585
|
+
return (p.get("recommendationTypeId"), resource, rec["sku"]), rec
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# ---------------------------------------------------------------- AI export
|
|
589
|
+
|
|
590
|
+
AI_INSTRUCTIONS = (
|
|
591
|
+
"This is an Azure cost breakdown exported by aztree. Amounts are in `currency`, for the cost type in `metric` "
|
|
592
|
+
"(ActualCost books reservation and savings plan purchases on the day they were bought; AmortizedCost spreads them "
|
|
593
|
+
"over the term). `current` is the most recent period and `previous` is the equally long period before it. "
|
|
594
|
+
"Line items are Azure meters grouped by service; `totals.credits_and_refunds` is the part of `current` that comes "
|
|
595
|
+
"from negative line items (credits, refunds). `flags` are known cost traps matched on meter names. `advisor` holds "
|
|
596
|
+
"Azure Advisor's cost recommendations, one per kind, resource and SKU, with the largest annual saving Advisor "
|
|
597
|
+
"reported; recommendations that cover the same usage (a reservation and a savings plan, a 1-year and a 3-year term) "
|
|
598
|
+
"are alternatives, not additive. "
|
|
599
|
+
"Please: 1) explain what drives the cost, 2) explain notable changes vs the previous period, "
|
|
600
|
+
"3) suggest concrete savings, each with an estimated monthly saving and how to verify it. "
|
|
601
|
+
"Levers to consider: reservations and savings plans for steady compute and databases; Azure Hybrid Benefit for "
|
|
602
|
+
"Windows Server and SQL Server licenses already owned; dev/test pricing for non-production subscriptions; "
|
|
603
|
+
"right-sizing and auto-shutdown for VMs and App Service plans; blob access tiers (cool, cold, archive) and lifecycle "
|
|
604
|
+
"rules; Log Analytics commitment tier pricing, Basic logs and shorter table retention; and keeping data transfer "
|
|
605
|
+
"inside one region."
|
|
606
|
+
)
|
|
607
|
+
TOP_RESOURCES = 20 # per resource group in the export; the rest are summed
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def summarize(data):
|
|
611
|
+
"""Turn the raw daily data into a compact JSON an AI agent can reason about."""
|
|
612
|
+
days, split = data["days"], data["split"]
|
|
613
|
+
n = len(days) - split
|
|
614
|
+
|
|
615
|
+
def money(v):
|
|
616
|
+
return round(v, 2)
|
|
617
|
+
|
|
618
|
+
def entry(cur, prev):
|
|
619
|
+
return {
|
|
620
|
+
"current": money(cur), "previous": money(prev), "change": money(cur - prev),
|
|
621
|
+
"change_pct": round(100 * (cur - prev) / prev, 1) if prev >= 0.01 else None,
|
|
622
|
+
"share_pct": round(100 * cur / grand, 2) if grand else 0,
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
def keep(cur, prev):
|
|
626
|
+
return abs(cur) >= 0.01 or abs(prev) >= 0.01
|
|
627
|
+
|
|
628
|
+
def grouped(view):
|
|
629
|
+
out = {}
|
|
630
|
+
for r in data["views"][view]["rows"]:
|
|
631
|
+
cur, prev = sum(r["d"][split:]), sum(r["d"][:split])
|
|
632
|
+
g = out.setdefault(r["k"][0], {"cur": 0, "prev": 0, "items": {}})
|
|
633
|
+
g["cur"] += cur
|
|
634
|
+
g["prev"] += prev
|
|
635
|
+
it = g["items"].setdefault(r["k"][1], [0, 0])
|
|
636
|
+
it[0] += cur
|
|
637
|
+
it[1] += prev
|
|
638
|
+
return sorted(out.items(), key=lambda kv: -kv[1]["cur"])
|
|
639
|
+
|
|
640
|
+
def breakdown(view, key_name, child_name, children="services", label=None, limit=None):
|
|
641
|
+
names = data["views"][view].get("names", {})
|
|
642
|
+
out = []
|
|
643
|
+
for k, g in grouped(view):
|
|
644
|
+
if not keep(g["cur"], g["prev"]):
|
|
645
|
+
continue
|
|
646
|
+
row = {key_name: label(k, names) if label else k}
|
|
647
|
+
if label:
|
|
648
|
+
row["id"] = k
|
|
649
|
+
elif names.get(k):
|
|
650
|
+
row["name"] = names[k]
|
|
651
|
+
row.update(entry(g["cur"], g["prev"]))
|
|
652
|
+
kids = sorted(({child_name: c, **entry(cc, p)} for c, (cc, p) in g["items"].items() if keep(cc, p)),
|
|
653
|
+
key=lambda x: -x["current"])
|
|
654
|
+
row[children] = kids[:limit]
|
|
655
|
+
if limit and len(kids) > limit:
|
|
656
|
+
row["other_" + children] = {"count": len(kids) - limit, "current": money(sum(c["current"] for c in kids[limit:]))}
|
|
657
|
+
out.append(row)
|
|
658
|
+
return out
|
|
659
|
+
|
|
660
|
+
services = grouped("service")
|
|
661
|
+
grand = sum(g["cur"] for _, g in services)
|
|
662
|
+
grand_prev = sum(g["prev"] for _, g in services)
|
|
663
|
+
|
|
664
|
+
line_items = []
|
|
665
|
+
for svc, g in services:
|
|
666
|
+
for meter, (cur, prev) in g["items"].items():
|
|
667
|
+
if keep(cur, prev):
|
|
668
|
+
line_items.append({"service": svc, "meter": meter, **entry(cur, prev)})
|
|
669
|
+
line_items.sort(key=lambda x: -x["current"])
|
|
670
|
+
|
|
671
|
+
growers = [x for x in line_items
|
|
672
|
+
if x["change"] >= max(1, grand * 0.005) and (x["change_pct"] is None or x["change_pct"] > 20)]
|
|
673
|
+
growers = sorted(growers, key=lambda x: -x["change"])[:10]
|
|
674
|
+
drops = [x for x in line_items # a refund (negative now) is a credit, not a saving: it goes in credits_and_refunds
|
|
675
|
+
if x["current"] > -0.01 and x["change"] <= -max(1, grand * 0.005) and (x["change_pct"] or 0) < -20]
|
|
676
|
+
drops = sorted(drops, key=lambda x: x["change"])[:10]
|
|
677
|
+
flags = []
|
|
678
|
+
for x in line_items:
|
|
679
|
+
why = pit(x["service"], x["meter"])
|
|
680
|
+
if why and x["current"] >= grand * 0.002:
|
|
681
|
+
flags.append({"service": x["service"], "meter": x["meter"], "current": x["current"], "reason": why})
|
|
682
|
+
|
|
683
|
+
daily = [0.0] * len(days)
|
|
684
|
+
for r in data["views"]["service"]["rows"]:
|
|
685
|
+
for i, v in enumerate(r["d"]):
|
|
686
|
+
daily[i] += v
|
|
687
|
+
|
|
688
|
+
return {
|
|
689
|
+
"tool": "aztree",
|
|
690
|
+
"instructions_for_ai": AI_INSTRUCTIONS,
|
|
691
|
+
"generated": data.get("generated"),
|
|
692
|
+
"metric": data.get("metric", "ActualCost"),
|
|
693
|
+
"currency": data.get("currency", "USD"),
|
|
694
|
+
"subscriptions": data.get("subscriptions", []),
|
|
695
|
+
"demo_data": bool(data.get("demo")),
|
|
696
|
+
"period": {
|
|
697
|
+
"current": {"start": days[split], "end": days[-1], "days": n},
|
|
698
|
+
"previous": {"start": days[0], "end": days[split - 1], "days": split},
|
|
699
|
+
},
|
|
700
|
+
"totals": {**entry(grand, grand_prev), "daily_avg": money(grand / n), "monthly_pace": money(grand / n * 30.4),
|
|
701
|
+
"credits_and_refunds": money(sum(min(x["current"], 0) for x in line_items))},
|
|
702
|
+
"by_service": [{"service": k, **entry(g["cur"], g["prev"])} for k, g in services if keep(g["cur"], g["prev"])],
|
|
703
|
+
"by_subscription": breakdown("subscription", "subscription_id", "service"),
|
|
704
|
+
"by_region": breakdown("region", "region", "service"),
|
|
705
|
+
"by_resource_group": breakdown("resource", "resource_group", "resource_id", "resources",
|
|
706
|
+
label=lambda k, names: names.get(k, k), limit=TOP_RESOURCES),
|
|
707
|
+
"top_growers": growers,
|
|
708
|
+
"top_drops": drops,
|
|
709
|
+
"flags": flags,
|
|
710
|
+
"advisor": data.get("advisor"),
|
|
711
|
+
"advisor_error": data.get("advisor_error"),
|
|
712
|
+
"line_items": line_items,
|
|
713
|
+
"daily_totals": [{"date": d, "cost": money(v)} for d, v in zip(days, daily)],
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def export(data, path):
|
|
718
|
+
path.write_text(json.dumps(summarize(data), indent=2, ensure_ascii=False), encoding="utf-8")
|
|
719
|
+
return path
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
# ---------------------------------------------------------------- output
|
|
723
|
+
|
|
724
|
+
def render(data, out):
|
|
725
|
+
html = template()
|
|
726
|
+
page = {**data, "pits": PITS, "export": summarize(data)}
|
|
727
|
+
# < keeps names like "</script>" or "<!--" from ending the script block early
|
|
728
|
+
blob = json.dumps(page, separators=(",", ":"), ensure_ascii=False).replace("<", "\\u003c")
|
|
729
|
+
out.write_text(html.replace("__AZTREE_DATA__", blob), encoding="utf-8")
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def resolve_targets(args, az, cli_list=None):
|
|
733
|
+
"""`cli_list` lists subscriptions through the Azure CLI (every tenant); without it, ARM lists the token's tenant."""
|
|
734
|
+
if args.scope:
|
|
735
|
+
return [scope_target(args.scope)]
|
|
736
|
+
current = None if (args.all or args.subscription) else current_subscription()
|
|
737
|
+
available = cli_list() if cli_list else list_subscriptions(az)
|
|
738
|
+
return [subscription_target(s) for s in pick_subscriptions(available, args.subscription, args.all, current)]
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def main(argv=None):
|
|
742
|
+
for stream in (sys.stdout, sys.stderr): # subscription names can hold characters a Windows pipe can't encode
|
|
743
|
+
if hasattr(stream, "reconfigure"):
|
|
744
|
+
stream.reconfigure(errors="replace")
|
|
745
|
+
saved = home() / "aztree-data.json"
|
|
746
|
+
ap = argparse.ArgumentParser(prog="aztree", description="See where your Azure money goes, as a treemap.")
|
|
747
|
+
ap.add_argument("--version", action="version", version=f"aztree {__version__}")
|
|
748
|
+
ap.add_argument("--demo", action="store_true", help="use fake data (no Azure access needed)")
|
|
749
|
+
who = ap.add_mutually_exclusive_group()
|
|
750
|
+
who.add_argument("--subscription", action="append", default=[], metavar="ID_OR_NAME",
|
|
751
|
+
help="subscription to read, repeat for more (default: the Azure CLI's current one)")
|
|
752
|
+
who.add_argument("--all", action="store_true", help="read every enabled subscription you can see")
|
|
753
|
+
who.add_argument("--scope", help="any Cost Management scope, e.g. a billing account (not tested yet)")
|
|
754
|
+
ap.add_argument("--days", type=int, default=30, help="period to show, compared with the period before it (default 30)")
|
|
755
|
+
ap.add_argument("--metric", default="ActualCost", choices=["ActualCost", "AmortizedCost"],
|
|
756
|
+
help="AmortizedCost spreads reservation and savings plan purchases over their term")
|
|
757
|
+
ap.add_argument("--no-advisor", action="store_true", help="skip Azure Advisor's cost recommendations")
|
|
758
|
+
ap.add_argument("--from", dest="source", nargs="?", const=str(saved), metavar="JSON",
|
|
759
|
+
help="reopen saved data without calling Azure (default: the last run)")
|
|
760
|
+
ap.add_argument("--out", default=str(home() / "aztree.html"), help="where to write the page (default ~/.aztree/aztree.html)")
|
|
761
|
+
ap.add_argument("--no-open", action="store_true", help="don't open the browser")
|
|
762
|
+
ap.add_argument("--export", nargs="?", const=str(home() / "aztree-export.json"), metavar="FILE",
|
|
763
|
+
help="write a summary JSON for an AI agent (default ~/.aztree/aztree-export.json) instead of the page")
|
|
764
|
+
ap.add_argument("--verbose", action="store_true", help="print pages, rows and query units per request")
|
|
765
|
+
args = ap.parse_args(argv)
|
|
766
|
+
|
|
767
|
+
if not 1 <= args.days <= 180:
|
|
768
|
+
die("--days must be between 1 and 180 (aztree reads two periods, and a query spans a year at most).")
|
|
769
|
+
|
|
770
|
+
if args.source:
|
|
771
|
+
source = Path(args.source)
|
|
772
|
+
if not source.exists():
|
|
773
|
+
die(f"no saved data at {source}. Read your costs once with `aztree` first (--demo runs aren't saved).")
|
|
774
|
+
data = json.loads(source.read_text(encoding="utf-8"))
|
|
775
|
+
elif args.demo:
|
|
776
|
+
data = demo(args.days)
|
|
777
|
+
data.update(generated=dt.datetime.now().strftime("%Y-%m-%d %H:%M"), metric=args.metric)
|
|
778
|
+
else:
|
|
779
|
+
try:
|
|
780
|
+
az = Azure(lambda tenant: get_token(tenant=tenant), verbose=args.verbose)
|
|
781
|
+
cli = shutil.which("az") and not os.environ.get("AZURE_ACCESS_TOKEN")
|
|
782
|
+
targets = resolve_targets(args, az, cli_list=cli_subscriptions if cli else None)
|
|
783
|
+
who_ = targets[0]["name"] if len(targets) == 1 else f"{len(targets)} subscriptions"
|
|
784
|
+
print(f"aztree: reading {who_}, last {args.days} days (+{args.days} before, for comparison)")
|
|
785
|
+
data = fetch(az, targets, args.days, args.metric, advisor=not args.no_advisor)
|
|
786
|
+
except AzureError as e:
|
|
787
|
+
die(explain(e))
|
|
788
|
+
data.update(generated=dt.datetime.now().strftime("%Y-%m-%d %H:%M"), metric=args.metric)
|
|
789
|
+
saved.parent.mkdir(parents=True, exist_ok=True)
|
|
790
|
+
saved.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
791
|
+
print(f"aztree: saved the data to {saved} (reopen it with: aztree --from)")
|
|
792
|
+
|
|
793
|
+
if args.export:
|
|
794
|
+
path = Path(args.export).resolve()
|
|
795
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
796
|
+
export(data, path)
|
|
797
|
+
print(f"aztree: wrote {path} (give this file to your AI agent)")
|
|
798
|
+
return
|
|
799
|
+
|
|
800
|
+
out = Path(args.out).resolve()
|
|
801
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
802
|
+
render(data, out)
|
|
803
|
+
print(f"aztree: wrote {out}")
|
|
804
|
+
if not args.no_open:
|
|
805
|
+
webbrowser.open(out.as_uri())
|