bicep-cost 0.1.0__tar.gz

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.
Files changed (53) hide show
  1. bicep_cost-0.1.0/.github/workflows/ci.yml +32 -0
  2. bicep_cost-0.1.0/.github/workflows/release.yml +73 -0
  3. bicep_cost-0.1.0/.gitignore +5 -0
  4. bicep_cost-0.1.0/PKG-INFO +272 -0
  5. bicep_cost-0.1.0/README.md +264 -0
  6. bicep_cost-0.1.0/bicep_cost/__init__.py +1 -0
  7. bicep_cost-0.1.0/bicep_cost/armexpr.py +872 -0
  8. bicep_cost-0.1.0/bicep_cost/calc.py +104 -0
  9. bicep_cost-0.1.0/bicep_cost/cli.py +414 -0
  10. bicep_cost-0.1.0/bicep_cost/collect/__init__.py +1 -0
  11. bicep_cost-0.1.0/bicep_cost/collect/arm.py +214 -0
  12. bicep_cost-0.1.0/bicep_cost/collect/collectors.py +543 -0
  13. bicep_cost-0.1.0/bicep_cost/collect/toml_out.py +43 -0
  14. bicep_cost-0.1.0/bicep_cost/compile.py +161 -0
  15. bicep_cost-0.1.0/bicep_cost/context.py +122 -0
  16. bicep_cost-0.1.0/bicep_cost/engine.py +544 -0
  17. bicep_cost-0.1.0/bicep_cost/estimators/__init__.py +99 -0
  18. bicep_cost-0.1.0/bicep_cost/estimators/aca.py +297 -0
  19. bicep_cost-0.1.0/bicep_cost/estimators/appservice.py +214 -0
  20. bicep_cost-0.1.0/bicep_cost/estimators/common.py +68 -0
  21. bicep_cost-0.1.0/bicep_cost/estimators/cron.py +72 -0
  22. bicep_cost-0.1.0/bicep_cost/estimators/keyvault.py +36 -0
  23. bicep_cost-0.1.0/bicep_cost/estimators/monitor.py +121 -0
  24. bicep_cost-0.1.0/bicep_cost/estimators/network.py +59 -0
  25. bicep_cost-0.1.0/bicep_cost/estimators/postgres.py +117 -0
  26. bicep_cost-0.1.0/bicep_cost/estimators/servicebus.py +55 -0
  27. bicep_cost-0.1.0/bicep_cost/estimators/sql.py +290 -0
  28. bicep_cost-0.1.0/bicep_cost/estimators/storage.py +128 -0
  29. bicep_cost-0.1.0/bicep_cost/estimators/vm.py +226 -0
  30. bicep_cost-0.1.0/bicep_cost/model.py +72 -0
  31. bicep_cost-0.1.0/bicep_cost/prices.py +345 -0
  32. bicep_cost-0.1.0/bicep_cost/pricesheet.py +192 -0
  33. bicep_cost-0.1.0/bicep_cost/report.py +314 -0
  34. bicep_cost-0.1.0/bicep_cost/usage.py +129 -0
  35. bicep_cost-0.1.0/examples/usage.toml +59 -0
  36. bicep_cost-0.1.0/pyproject.toml +36 -0
  37. bicep_cost-0.1.0/tests/__init__.py +0 -0
  38. bicep_cost-0.1.0/tests/conftest.py +281 -0
  39. bicep_cost-0.1.0/tests/fixtures/app.json +43 -0
  40. bicep_cost-0.1.0/tests/fixtures/main.bicep +14 -0
  41. bicep_cost-0.1.0/tests/fixtures/prod.bicepparam +3 -0
  42. bicep_cost-0.1.0/tests/test_armexpr.py +100 -0
  43. bicep_cost-0.1.0/tests/test_cli.py +324 -0
  44. bicep_cost-0.1.0/tests/test_collect.py +348 -0
  45. bicep_cost-0.1.0/tests/test_cron.py +32 -0
  46. bicep_cost-0.1.0/tests/test_engine.py +202 -0
  47. bicep_cost-0.1.0/tests/test_estimators.py +447 -0
  48. bicep_cost-0.1.0/tests/test_prices.py +107 -0
  49. bicep_cost-0.1.0/tests/test_pricesheet.py +199 -0
  50. bicep_cost-0.1.0/tests/test_report.py +28 -0
  51. bicep_cost-0.1.0/tests/test_servicebus.py +42 -0
  52. bicep_cost-0.1.0/tests/test_sql_vm.py +227 -0
  53. bicep_cost-0.1.0/uv.lock +137 -0
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ workflow_call:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ check:
12
+ name: Python ${{ matrix.python }}
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ python: ['3.11', '3.14']
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: astral-sh/setup-uv@v7
20
+ with:
21
+ python-version: ${{ matrix.python }}
22
+ - run: uv sync --locked --all-groups
23
+ - run: uv run ruff check .
24
+ - run: uv run ty check
25
+ - run: uv run pytest
26
+ - run: uv build
27
+ - name: Smoke-test installed wheel outside the checkout
28
+ run: |
29
+ uv venv /tmp/bicep-cost-smoke
30
+ uv pip install --python /tmp/bicep-cost-smoke/bin/python dist/*.whl
31
+ cd /tmp
32
+ /tmp/bicep-cost-smoke/bin/bicep-cost --help
@@ -0,0 +1,73 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+ inputs:
8
+ dry_run:
9
+ description: Validate and report the release without publishing
10
+ type: boolean
11
+ default: true
12
+
13
+ permissions:
14
+ contents: read
15
+
16
+ concurrency:
17
+ group: release
18
+ cancel-in-progress: false
19
+
20
+ jobs:
21
+ test:
22
+ uses: ./.github/workflows/ci.yml
23
+
24
+ plan:
25
+ needs: test
26
+ runs-on: ubuntu-latest
27
+ outputs:
28
+ version: ${{ steps.plan.outputs.version }}
29
+ release: ${{ steps.plan.outputs.release }}
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - uses: actions/setup-python@v5
33
+ with:
34
+ python-version: '3.14'
35
+ - id: plan
36
+ env:
37
+ GH_TOKEN: ${{ github.token }}
38
+ DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
39
+ run: |
40
+ VERSION=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
41
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
42
+ if gh release view "v$VERSION" >/dev/null 2>&1; then
43
+ echo 'release=false' >> "$GITHUB_OUTPUT"
44
+ echo "v$VERSION already released" >> "$GITHUB_STEP_SUMMARY"
45
+ else
46
+ echo 'release=true' >> "$GITHUB_OUTPUT"
47
+ echo "Ready to release v$VERSION (dry run: $DRY_RUN)" >> "$GITHUB_STEP_SUMMARY"
48
+ fi
49
+ publish:
50
+ needs: [test, plan]
51
+ if: >-
52
+ github.ref == 'refs/heads/main' && needs.plan.outputs.release == 'true' &&
53
+ (github.event_name != 'workflow_dispatch' || !inputs.dry_run)
54
+ runs-on: ubuntu-latest
55
+ environment: pypi
56
+ permissions:
57
+ contents: write
58
+ id-token: write
59
+ steps:
60
+ - uses: actions/checkout@v4
61
+ - uses: astral-sh/setup-uv@v7
62
+ with:
63
+ python-version: '3.14'
64
+ - run: uv build
65
+ - name: Publish to PyPI with OIDC
66
+ uses: pypa/gh-action-pypi-publish@release/v1
67
+ with:
68
+ skip-existing: true
69
+ - name: Create GitHub release and tag
70
+ env:
71
+ GH_TOKEN: ${{ github.token }}
72
+ VERSION: ${{ needs.plan.outputs.version }}
73
+ run: gh release create "v$VERSION" dist/* --target "$GITHUB_SHA" --title "v$VERSION" --generate-notes
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ dist/
@@ -0,0 +1,272 @@
1
+ Metadata-Version: 2.5
2
+ Name: bicep-cost
3
+ Version: 0.1.0
4
+ Summary: Estimate monthly Azure costs from Bicep / ARM templates using the public Azure Retail Prices API
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+
9
+ # bicep-cost
10
+
11
+ Estimate the monthly Azure cost of a Bicep (or compiled ARM) deployment from the command line, using the public
12
+ [Azure Retail Prices API](https://learn.microsoft.com/rest/api/cost-management/retail-prices/azure-retail-prices)
13
+ (the same list prices the Azure pricing calculator uses). No Azure login needed.
14
+
15
+ ```bash
16
+ uv tool install . # or: uvx --from . bicep-cost ...
17
+
18
+ bicep-cost estimate app/infra/main.bicep --set administratorLoginPassword=x
19
+ bicep-cost estimate other-app/infra/parameters/prod.bicepparam # uses the file's `using` template
20
+ bicep-cost estimate main.bicep -p parameters/prod.bicepparam --currency CHF --detail
21
+ bicep-cost estimate compiled.json -p main.parameters.json --format markdown # azd ${VAR} placeholders read from env
22
+ ```
23
+
24
+ ```
25
+ RESOURCE FIXED USAGE* TOTAL
26
+ serverfarms: app-plan 259.88 0.00 259.88
27
+ containerApps: app-api 217.28 0.00 217.28
28
+ flexibleServers: app-postgres 152.57 0.00 152.57
29
+ ...
30
+ Provisioned (fixed): 869.11 USD
31
+ Usage-based* (assumed): 43.91 USD
32
+ TOTAL per month: 913.02 USD
33
+ ```
34
+
35
+ ## How it works
36
+
37
+ 1. `.bicep` / `.bicepparam` are compiled with the Bicep CLI (`$BICEP_CLI`, `bicep` on PATH, `~/.azure/bin/bicep`, or
38
+ `az bicep`). A compiled ARM `.json` skips this step.
39
+ 2. A small ARM evaluator expands the template: parameters, variables, `condition`, `copy` loops, nested deployments
40
+ (Bicep modules) and their outputs. Values that only exist after deployment (`reference()` of live resources,
41
+ `listKeys()`, unset parameters) become *unknown* and never silently turn into guesses.
42
+ 3. One estimator per resource type turns resources into meter quantities; prices are looked up in the Retail Prices
43
+ API (cached in `~/.cache/bicep-cost`, 7 days) and tiers / free grants are applied per subscription or workspace.
44
+
45
+ ## Fixed vs. usage-based
46
+
47
+ Every line is either **fixed** (follows from the template: plan SKU x instances, database compute, always-on
48
+ replicas, private endpoint hours, alert rules, cron-scheduled replicas) or **usage-based\*** (depends on traffic or
49
+ data volume: storage GB and operations, log ingestion, session hours, function executions, AI tokens). Without
50
+ measurements, usage-based numbers come from small built-in defaults (e.g. 10 GB per storage account, 2 GB of logs) -
51
+ good enough to see the shape, not to quote. `--detail` lists every assumption used and the report says how many
52
+ figures are still defaults.
53
+
54
+ ### Measure the real values from a deployed environment
55
+
56
+ ```bash
57
+ az login
58
+ bicep-cost collect app/infra/main.bicep --set administratorLoginPassword=x \
59
+ --resource-group my-resource-group # -> main.usage.toml (next to the template)
60
+ bicep-cost collect other-app/infra/main.bicep -p other-app/infra/parameters/prod.bicepparam \
61
+ --resource-group <rg> --days 60 # -> parameters/prod.usage.toml
62
+
63
+ bicep-cost estimate app/infra/main.bicep ... # picks main.usage.toml up automatically
64
+ ```
65
+
66
+ `collect` is **read-only** (GETs, Azure Monitor metric reads and two read-only POST queries: Log Analytics `Usage` and
67
+ Cost Management `query`). It evaluates the template with the real subscription and resource group so that resource ids
68
+ match the deployed ones, then writes a commented usage file:
69
+
70
+ | Resource | Measured from | Usage key |
71
+ |---|---|---|
72
+ | Storage account | blob service `BlobCapacity`, `Transactions` by `ApiName` (read / write / other) | `stored_gb`, `*_operations_10k` |
73
+ | File share | `FileCapacity`, `Transactions` (account total split over its shares on standard accounts) | `used_gb`, `*_operations_10k` |
74
+ | Log Analytics | `Usage` table (billable GB) + top tables; App Insights in the same workspace is set to 0 | `ingest_gb_per_month` |
75
+ | Container App | `Replicas` (hourly), `UsageNanoCores`/`RxBytes`/`TxBytes` (5-min idle share), `Requests` | `replica_hours`, `idle_percent`, `requests_millions` |
76
+ | Session pool, AI/Foundry account | Cost Management actual cost (pool metrics do not reflect billed session-hours) | `monthly_cost` |
77
+ | Function app (Consumption/Flex) | `FunctionExecutionCount`, `FunctionExecutionUnits` | `executions_per_month`, `gb_seconds_per_month` |
78
+ | Key Vault / private endpoint / private DNS zone / Service Bus | `ServiceApiHit` / `PEBytesIn/Out` / `QueryVolume` / message counts | `operations_10k` / `*_gb` / `queries_millions` / `operations_millions` |
79
+ | App Service plan, PostgreSQL | live resource: instance count, `storageSizeGB` (auto-grow) | `instances`, `storage_gb` |
80
+
81
+ It also prints **drift** (template says 32 GB / 2 instances, live says 64 GB / 3) and each resource's actual cost of the
82
+ window as a comment, so you can sanity-check the estimate. Files are merged key by key (`--usage a.toml --usage
83
+ b.toml`, later wins), so hand-written overrides can live in a separate file. `bicep-cost init-usage main.bicep` writes a
84
+ file with every tunable value and its default instead.
85
+
86
+ Usage file lookup for `estimate`: explicit `--usage` (repeatable), else `bicep-cost.usage.toml` and
87
+ `<params-or-template stem>.usage.toml` next to the input. Tables match the resource name, exact or glob
88
+ (`[storage."stprod*"]`); see [`examples/usage.toml`](examples/usage.toml).
89
+
90
+ Caveats of the measurements: windows are scaled to a month (730 h); `idle_percent` is judged on 5-minute windows and
91
+ can be lower than what is billed; Container Apps free grants are subscription-wide, so if other apps in the
92
+ subscription use them up, the estimate understates; `monthly_cost` values are in the billing currency recorded as
93
+ `[meta] cost_currency` (the estimate then defaults to that currency, no conversion happens).
94
+
95
+ ### Negotiated discounts
96
+
97
+ List prices are what the API returns. If you pay less (EA/MCA discounts, reservations), say so per resource type:
98
+
99
+ ```toml
100
+ [discounts]
101
+ "Microsoft.Web/serverfarms" = 30 # percent off list
102
+ "Microsoft.DBforPostgreSQL/flexibleServers" = 30
103
+ default = 0
104
+ ```
105
+
106
+ ## Supported resource types and tiers
107
+
108
+ | Area | Types | Tiers / SKUs covered |
109
+ |---|---|---|
110
+ | App Service | `serverfarms`, `sites`, `slots` | Free; Basic B1-3; Standard S1-3; Premium v2/v3/v4 (incl. `P1mv3`); Isolated v2 (stamp fee not included); Linux and Windows. Sites/slots ride on the plan; sites on plans outside the template are reported as *external* |
111
+ | Functions / Logic Apps | `serverfarms` + `sites` | Consumption (Y1), Flex (FC1: always-ready + on-demand), Elastic Premium EP1-3, Logic Apps Standard WS1-3 |
112
+ | Container Apps | `managedEnvironments`, `containerApps`, `sessionPools` | Consumption profile (active/idle rates, free grants, KEDA cron schedules), dedicated profiles D4-D32 / E4-E32 (+ plan management fee), dynamic sessions (Python/Node) and custom-container pools |
113
+ | Storage | `storageAccounts`, file `shares` | LRS / ZRS / GRS / RA-GRS / GZRS / RA-GZRS; Hot / Cool / Cold; Blob, ADLS Gen2 (HNS), standard file shares (TransactionOptimized / Hot / Cool). **Not** Premium |
114
+ | PostgreSQL | `flexibleServers` | Burstable, General Purpose, Memory Optimized (any series); HA doubles compute + storage; Premium SSD v2 IOPS / throughput; backup storage above the free allowance (geo-redundant aware) |
115
+ | Azure SQL | `servers/databases`, `servers/elasticPools`, `managedInstances` | vCore: General Purpose (provisioned and serverless), Business Critical, Hyperscale (+ HA replicas), zone redundancy, Gen5 / DC / Fsv2 / M families; DTU: Basic, Standard S0-S12, Premium P1-P15; DTU elastic pools; Managed Instance GP / BC. Databases in a pool are included. Usage keys use the full name `server/database`. Azure Hybrid Benefit is not applied (price shown includes the license). **Not** Synapse dedicated pools, LTR backups |
116
+ | Virtual machines | `virtualMachines`, `virtualMachineScaleSets`, `disks`, `publicIPAddresses` | any VM size (by `armSkuName`), Linux / Windows / Hybrid Benefit, Spot; OS + data disks (Premium / Standard SSD / Standard HDD, LRS / ZRS, tier from size); ephemeral OS disks free; VMSS = instances x VM (set `instances` for autoscale). **Not** Ultra / Premium SSD v2 disks, SQL / RHEL / SUSE license surcharges, load balancers, gateways |
117
+ | Service Bus | `namespaces` | Basic, Standard (base fee + tiered operations, first 13 M included), Premium (1/2/4/8/16 messaging units) |
118
+ | Monitoring | `workspaces`, `components`, `scheduledQueryRules` | PerGB2018 (free 5 GB, tiers), commitment tiers, retention > 31 d. **Not** Basic/Auxiliary table plans |
119
+ | Key Vault | `vaults` | Standard / Premium operations. **Not** HSM keys or managed HSM |
120
+ | Network | `privateEndpoints`, `privateDnsZones` | per-hour + data processed; zone tiers (first 25 zones). **Not** gateways, firewalls, public IPs |
121
+ | AI | `CognitiveServices/accounts` | no base fee; spend via `monthly_cost` (measured by `collect`). **Not** provisioned throughput (PTU) |
122
+ | Free (explicitly known) | role assignments, VNets/subnets, DNS records and links, budgets, dashboards, diagnostic settings, secrets, access policies, databases/configs, blob containers, queues/topics, ... | |
123
+
124
+ Anything else is listed under *Not covered by this tool (cost NOT included)*; use `--strict` to fail in that case.
125
+ Premium storage, ASE stamps, bandwidth/egress and reservations are not modelled; prices are pay-as-you-go **list
126
+ prices** unless you add `[discounts]` or opt into account price sheets (below).
127
+
128
+ Add a resource type by writing `estimate_x(res, ctx) -> ResourceEstimate` in `bicep_cost/estimators/` and registering it
129
+ in `estimators/__init__.py`.
130
+
131
+ ## Other commands and flags
132
+
133
+ ```bash
134
+ bicep-cost estimate ... --format json -o head.json # also: markdown (PR comments), csv
135
+ bicep-cost diff base.json head.json # per-resource cost change between two reports
136
+ bicep-cost estimate ... --fail-above 1500 # exit 3 above a monthly budget (CI guard rail)
137
+ bicep-cost estimate ... --strict # exit 2 if something could not be priced
138
+ bicep-cost estimate ... --group-by type|module # summary table instead of per-resource rows
139
+ bicep-cost prices --service "Azure Container Apps" --meter-contains Idle # browse meters
140
+ ```
141
+
142
+ Other options: `--location` (default region and `resourceGroup().location`, default `westeurope`), `--resource-group`,
143
+ `--subscription-id`, `--offline` / `--refresh` / `--no-cache`, `-v` (log API calls).
144
+
145
+ ## Development
146
+
147
+ ```bash
148
+ uv sync --group test --group dev
149
+ uv run -m pytest
150
+ uv run ruff check . && uv run ty check
151
+ ```
152
+
153
+ ## Currency
154
+
155
+ Estimates use **USD** by default. Set `--currency CHF` (or `EUR`, for example) on
156
+ `estimate` and `prices` to request that currency directly from Azure:
157
+
158
+ ```sh
159
+ bicep-cost estimate main.bicep --currency CHF
160
+ bicep-cost prices --service "Key Vault" --currency EUR
161
+ ```
162
+
163
+ For estimates the precedence is `--currency`, then `[meta] cost_currency` in the
164
+ usage TOML, then USD. Codes are case insensitive. Reports and budget limits use
165
+ the selected currency; cached prices are isolated by currency. Manual
166
+ `monthly_cost` values must use the same currency as the estimate; conflicting
167
+ usage-file metadata is rejected. No exchange-rate conversion is performed.
168
+ Diffs require both reports to use the same currency.
169
+
170
+ Azure's non-USD retail rates are budget estimates, not negotiated billing rates.
171
+ See [Microsoft's Retail Prices API currency documentation](https://learn.microsoft.com/rest/api/cost-management/retail-prices/azure-retail-prices).
172
+
173
+ ## Percentage cost caps in CI
174
+
175
+ Save a baseline estimate (using the same currency and usage assumptions), then
176
+ fail if the new monthly cost grows by more than 10 percent:
177
+
178
+ ```sh
179
+ bicep-cost estimate main.bicep --format json -o base.json
180
+ # After changing the template:
181
+ bicep-cost estimate main.bicep --baseline base.json --fail-increase-percent 10 --fail-above 1500
182
+ # Or compare two saved JSON reports:
183
+ bicep-cost diff base.json head.json --fail-increase-percent 10 --fail-above 1500
184
+ ```
185
+
186
+ Either exceeded cap returns **3**. Equality and decreases pass; any positive cost
187
+ from a zero baseline fails the relative cap. Thresholds must be finite and
188
+ nonnegative. The full report is written before a budget failure, including with
189
+ `--output`, so CI can always retain it. `--strict` failures still take precedence
190
+ (exit 2). Baseline reports must use the same currency; no conversion is applied.
191
+
192
+ ## Cost breakdown charts
193
+
194
+ Text and Markdown estimates include horizontal bars ordered from the most
195
+ expensive resource to the least. Each bar shows the monthly amount and share of
196
+ the known total. Text `--group-by type` or `--group-by module` aggregates both the
197
+ chart and table. Zero-cost resources do not get bars; unresolved and unsupported
198
+ resources remain listed below, since their costs are not included in the total.
199
+ Charts are plain text for readable CI logs and Markdown PR comments, and are
200
+ written even when a budget cap fails. JSON and CSV remain machine-readable.
201
+
202
+ ## Automatic releases
203
+
204
+ Like [bmsui](https://github.com/bmsuisse/bmsui/blob/main/.github/workflows/release.yml),
205
+ main pushes run reusable CI, detect a new package version, publish to PyPI using
206
+ OIDC, and create a GitHub release with wheel/sdist assets. PR CI checks Ruff, ty,
207
+ tests, builds and an installed-wheel smoke test on Python 3.11 and 3.14.
208
+
209
+ Before the first merge, a PyPI maintainer must register a
210
+ [pending trusted publisher](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/):
211
+ project **bicep-cost**, owner **bmsuisse**, repository **bicep-cost**, workflow
212
+ **release.yml**, environment **pypi**. Use the same environment in GitHub;
213
+ restrict its deployment branches to `main`. No long-lived PyPI token is needed.
214
+
215
+ Bump `[project] version` in `pyproject.toml`, run `uv lock`, and merge the PR.
216
+ Main publishes that version after CI passes; an existing GitHub release is
217
+ skipped. The Release workflow can also be dispatched manually from main; its
218
+ **dry_run** input defaults to true, validating without publishing. Release jobs
219
+ are serialized. If publication succeeded but release creation failed, rerun the
220
+ workflow: existing PyPI files are skipped and GitHub release creation is retried.
221
+ Do not change files for a version already uploaded to PyPI; bump the version.
222
+
223
+ ## Azure account prices
224
+
225
+ Retail prices remain the default and require no login. To use negotiated
226
+ consumption rates through the logged-in Azure CLI user:
227
+
228
+ ```sh
229
+ az login
230
+ bicep-cost estimate main.bicep --azure-prices --subscription-id YOUR_SUBSCRIPTION_ID
231
+ # Persist a private snapshot while estimating:
232
+ bicep-cost estimate main.bicep --azure-prices --save-price-sheet account-prices.json
233
+ # Or download separately:
234
+ bicep-cost price-sheet --subscription-id YOUR_SUBSCRIPTION_ID -o account-prices.json
235
+ # Reuse without Azure login:
236
+ bicep-cost estimate main.bicep --price-sheet account-prices.json
237
+ ```
238
+
239
+ Without a subscription argument, downloads use the CLI's current subscription.
240
+ The read-only [Consumption Price Sheet API](https://learn.microsoft.com/rest/api/consumption/price-sheet/get)
241
+ requires access to the subscription's pricing and a supported billing agreement.
242
+ An ordinary Azure login alone does not guarantee access. Unsupported agreements,
243
+ missing sheets and denied access produce an error. Within a successfully loaded
244
+ price sheet, missing meter rates fall back to retail prices with warnings.
245
+
246
+ For MCA/MPA accounts, or when subscription downloads are unavailable, download
247
+ and extract the current consumption price-sheet CSV from
248
+ [Cost Management + Billing](https://learn.microsoft.com/azure/cost-management-billing/manage/ea-pricing),
249
+ then use `--price-sheet prices.csv`. The importer accepts case-insensitive column
250
+ names `meterId`, optional `skuId`, `currencyCode` or `currency`, `unitOfMeasure`,
251
+ `unitPrice`, optional `tierMinimumUnits` and `priceType`. It uses **price currency**,
252
+ not `billingCurrency`; reservations and savings-plan rows are excluded. Import a
253
+ single current price period: conflicting duplicate rates are rejected. Legacy
254
+ CSV layouts must be converted to these columns first. Included-quantity grants
255
+ are rejected because the sheet does not supply enough billing-scope information
256
+ to allocate them safely.
257
+
258
+ Account mode discovers meter identities through the Retail API, then replaces
259
+ rates by meter/SKU ID, normalizing compatible block units (for example 100 Hours
260
+ to 1 Hour). Missing account meter rates use retail prices with an explicit pricing
261
+ warning in text, Markdown, and JSON reports; `--strict` does not block this
262
+ fallback. Resources with no retail meter still become unresolved. Manual `[discounts]` are ignored because account rates
263
+ already include discounts. Reports record the sheet's origin and snapshot time.
264
+ Explicit `--currency` and usage-file currency must match the sheet; by default
265
+ its currency is selected. No exchange-rate conversion is performed.
266
+
267
+ Snapshots contain negotiated rates, not Azure tokens, and are saved atomically
268
+ with owner-only permissions. Keep them private and out of source control. Saved
269
+ sheets can be used without login; completely offline estimates also need the
270
+ Retail API metadata in the normal price cache. Use `--price-sheet FILE --offline`
271
+ for that; `--azure-prices` always downloads fresh rates. Snapshots are not refreshed
272
+ automatically, so download a new sheet when your agreement or billing period changes.
@@ -0,0 +1,264 @@
1
+ # bicep-cost
2
+
3
+ Estimate the monthly Azure cost of a Bicep (or compiled ARM) deployment from the command line, using the public
4
+ [Azure Retail Prices API](https://learn.microsoft.com/rest/api/cost-management/retail-prices/azure-retail-prices)
5
+ (the same list prices the Azure pricing calculator uses). No Azure login needed.
6
+
7
+ ```bash
8
+ uv tool install . # or: uvx --from . bicep-cost ...
9
+
10
+ bicep-cost estimate app/infra/main.bicep --set administratorLoginPassword=x
11
+ bicep-cost estimate other-app/infra/parameters/prod.bicepparam # uses the file's `using` template
12
+ bicep-cost estimate main.bicep -p parameters/prod.bicepparam --currency CHF --detail
13
+ bicep-cost estimate compiled.json -p main.parameters.json --format markdown # azd ${VAR} placeholders read from env
14
+ ```
15
+
16
+ ```
17
+ RESOURCE FIXED USAGE* TOTAL
18
+ serverfarms: app-plan 259.88 0.00 259.88
19
+ containerApps: app-api 217.28 0.00 217.28
20
+ flexibleServers: app-postgres 152.57 0.00 152.57
21
+ ...
22
+ Provisioned (fixed): 869.11 USD
23
+ Usage-based* (assumed): 43.91 USD
24
+ TOTAL per month: 913.02 USD
25
+ ```
26
+
27
+ ## How it works
28
+
29
+ 1. `.bicep` / `.bicepparam` are compiled with the Bicep CLI (`$BICEP_CLI`, `bicep` on PATH, `~/.azure/bin/bicep`, or
30
+ `az bicep`). A compiled ARM `.json` skips this step.
31
+ 2. A small ARM evaluator expands the template: parameters, variables, `condition`, `copy` loops, nested deployments
32
+ (Bicep modules) and their outputs. Values that only exist after deployment (`reference()` of live resources,
33
+ `listKeys()`, unset parameters) become *unknown* and never silently turn into guesses.
34
+ 3. One estimator per resource type turns resources into meter quantities; prices are looked up in the Retail Prices
35
+ API (cached in `~/.cache/bicep-cost`, 7 days) and tiers / free grants are applied per subscription or workspace.
36
+
37
+ ## Fixed vs. usage-based
38
+
39
+ Every line is either **fixed** (follows from the template: plan SKU x instances, database compute, always-on
40
+ replicas, private endpoint hours, alert rules, cron-scheduled replicas) or **usage-based\*** (depends on traffic or
41
+ data volume: storage GB and operations, log ingestion, session hours, function executions, AI tokens). Without
42
+ measurements, usage-based numbers come from small built-in defaults (e.g. 10 GB per storage account, 2 GB of logs) -
43
+ good enough to see the shape, not to quote. `--detail` lists every assumption used and the report says how many
44
+ figures are still defaults.
45
+
46
+ ### Measure the real values from a deployed environment
47
+
48
+ ```bash
49
+ az login
50
+ bicep-cost collect app/infra/main.bicep --set administratorLoginPassword=x \
51
+ --resource-group my-resource-group # -> main.usage.toml (next to the template)
52
+ bicep-cost collect other-app/infra/main.bicep -p other-app/infra/parameters/prod.bicepparam \
53
+ --resource-group <rg> --days 60 # -> parameters/prod.usage.toml
54
+
55
+ bicep-cost estimate app/infra/main.bicep ... # picks main.usage.toml up automatically
56
+ ```
57
+
58
+ `collect` is **read-only** (GETs, Azure Monitor metric reads and two read-only POST queries: Log Analytics `Usage` and
59
+ Cost Management `query`). It evaluates the template with the real subscription and resource group so that resource ids
60
+ match the deployed ones, then writes a commented usage file:
61
+
62
+ | Resource | Measured from | Usage key |
63
+ |---|---|---|
64
+ | Storage account | blob service `BlobCapacity`, `Transactions` by `ApiName` (read / write / other) | `stored_gb`, `*_operations_10k` |
65
+ | File share | `FileCapacity`, `Transactions` (account total split over its shares on standard accounts) | `used_gb`, `*_operations_10k` |
66
+ | Log Analytics | `Usage` table (billable GB) + top tables; App Insights in the same workspace is set to 0 | `ingest_gb_per_month` |
67
+ | Container App | `Replicas` (hourly), `UsageNanoCores`/`RxBytes`/`TxBytes` (5-min idle share), `Requests` | `replica_hours`, `idle_percent`, `requests_millions` |
68
+ | Session pool, AI/Foundry account | Cost Management actual cost (pool metrics do not reflect billed session-hours) | `monthly_cost` |
69
+ | Function app (Consumption/Flex) | `FunctionExecutionCount`, `FunctionExecutionUnits` | `executions_per_month`, `gb_seconds_per_month` |
70
+ | Key Vault / private endpoint / private DNS zone / Service Bus | `ServiceApiHit` / `PEBytesIn/Out` / `QueryVolume` / message counts | `operations_10k` / `*_gb` / `queries_millions` / `operations_millions` |
71
+ | App Service plan, PostgreSQL | live resource: instance count, `storageSizeGB` (auto-grow) | `instances`, `storage_gb` |
72
+
73
+ It also prints **drift** (template says 32 GB / 2 instances, live says 64 GB / 3) and each resource's actual cost of the
74
+ window as a comment, so you can sanity-check the estimate. Files are merged key by key (`--usage a.toml --usage
75
+ b.toml`, later wins), so hand-written overrides can live in a separate file. `bicep-cost init-usage main.bicep` writes a
76
+ file with every tunable value and its default instead.
77
+
78
+ Usage file lookup for `estimate`: explicit `--usage` (repeatable), else `bicep-cost.usage.toml` and
79
+ `<params-or-template stem>.usage.toml` next to the input. Tables match the resource name, exact or glob
80
+ (`[storage."stprod*"]`); see [`examples/usage.toml`](examples/usage.toml).
81
+
82
+ Caveats of the measurements: windows are scaled to a month (730 h); `idle_percent` is judged on 5-minute windows and
83
+ can be lower than what is billed; Container Apps free grants are subscription-wide, so if other apps in the
84
+ subscription use them up, the estimate understates; `monthly_cost` values are in the billing currency recorded as
85
+ `[meta] cost_currency` (the estimate then defaults to that currency, no conversion happens).
86
+
87
+ ### Negotiated discounts
88
+
89
+ List prices are what the API returns. If you pay less (EA/MCA discounts, reservations), say so per resource type:
90
+
91
+ ```toml
92
+ [discounts]
93
+ "Microsoft.Web/serverfarms" = 30 # percent off list
94
+ "Microsoft.DBforPostgreSQL/flexibleServers" = 30
95
+ default = 0
96
+ ```
97
+
98
+ ## Supported resource types and tiers
99
+
100
+ | Area | Types | Tiers / SKUs covered |
101
+ |---|---|---|
102
+ | App Service | `serverfarms`, `sites`, `slots` | Free; Basic B1-3; Standard S1-3; Premium v2/v3/v4 (incl. `P1mv3`); Isolated v2 (stamp fee not included); Linux and Windows. Sites/slots ride on the plan; sites on plans outside the template are reported as *external* |
103
+ | Functions / Logic Apps | `serverfarms` + `sites` | Consumption (Y1), Flex (FC1: always-ready + on-demand), Elastic Premium EP1-3, Logic Apps Standard WS1-3 |
104
+ | Container Apps | `managedEnvironments`, `containerApps`, `sessionPools` | Consumption profile (active/idle rates, free grants, KEDA cron schedules), dedicated profiles D4-D32 / E4-E32 (+ plan management fee), dynamic sessions (Python/Node) and custom-container pools |
105
+ | Storage | `storageAccounts`, file `shares` | LRS / ZRS / GRS / RA-GRS / GZRS / RA-GZRS; Hot / Cool / Cold; Blob, ADLS Gen2 (HNS), standard file shares (TransactionOptimized / Hot / Cool). **Not** Premium |
106
+ | PostgreSQL | `flexibleServers` | Burstable, General Purpose, Memory Optimized (any series); HA doubles compute + storage; Premium SSD v2 IOPS / throughput; backup storage above the free allowance (geo-redundant aware) |
107
+ | Azure SQL | `servers/databases`, `servers/elasticPools`, `managedInstances` | vCore: General Purpose (provisioned and serverless), Business Critical, Hyperscale (+ HA replicas), zone redundancy, Gen5 / DC / Fsv2 / M families; DTU: Basic, Standard S0-S12, Premium P1-P15; DTU elastic pools; Managed Instance GP / BC. Databases in a pool are included. Usage keys use the full name `server/database`. Azure Hybrid Benefit is not applied (price shown includes the license). **Not** Synapse dedicated pools, LTR backups |
108
+ | Virtual machines | `virtualMachines`, `virtualMachineScaleSets`, `disks`, `publicIPAddresses` | any VM size (by `armSkuName`), Linux / Windows / Hybrid Benefit, Spot; OS + data disks (Premium / Standard SSD / Standard HDD, LRS / ZRS, tier from size); ephemeral OS disks free; VMSS = instances x VM (set `instances` for autoscale). **Not** Ultra / Premium SSD v2 disks, SQL / RHEL / SUSE license surcharges, load balancers, gateways |
109
+ | Service Bus | `namespaces` | Basic, Standard (base fee + tiered operations, first 13 M included), Premium (1/2/4/8/16 messaging units) |
110
+ | Monitoring | `workspaces`, `components`, `scheduledQueryRules` | PerGB2018 (free 5 GB, tiers), commitment tiers, retention > 31 d. **Not** Basic/Auxiliary table plans |
111
+ | Key Vault | `vaults` | Standard / Premium operations. **Not** HSM keys or managed HSM |
112
+ | Network | `privateEndpoints`, `privateDnsZones` | per-hour + data processed; zone tiers (first 25 zones). **Not** gateways, firewalls, public IPs |
113
+ | AI | `CognitiveServices/accounts` | no base fee; spend via `monthly_cost` (measured by `collect`). **Not** provisioned throughput (PTU) |
114
+ | Free (explicitly known) | role assignments, VNets/subnets, DNS records and links, budgets, dashboards, diagnostic settings, secrets, access policies, databases/configs, blob containers, queues/topics, ... | |
115
+
116
+ Anything else is listed under *Not covered by this tool (cost NOT included)*; use `--strict` to fail in that case.
117
+ Premium storage, ASE stamps, bandwidth/egress and reservations are not modelled; prices are pay-as-you-go **list
118
+ prices** unless you add `[discounts]` or opt into account price sheets (below).
119
+
120
+ Add a resource type by writing `estimate_x(res, ctx) -> ResourceEstimate` in `bicep_cost/estimators/` and registering it
121
+ in `estimators/__init__.py`.
122
+
123
+ ## Other commands and flags
124
+
125
+ ```bash
126
+ bicep-cost estimate ... --format json -o head.json # also: markdown (PR comments), csv
127
+ bicep-cost diff base.json head.json # per-resource cost change between two reports
128
+ bicep-cost estimate ... --fail-above 1500 # exit 3 above a monthly budget (CI guard rail)
129
+ bicep-cost estimate ... --strict # exit 2 if something could not be priced
130
+ bicep-cost estimate ... --group-by type|module # summary table instead of per-resource rows
131
+ bicep-cost prices --service "Azure Container Apps" --meter-contains Idle # browse meters
132
+ ```
133
+
134
+ Other options: `--location` (default region and `resourceGroup().location`, default `westeurope`), `--resource-group`,
135
+ `--subscription-id`, `--offline` / `--refresh` / `--no-cache`, `-v` (log API calls).
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ uv sync --group test --group dev
141
+ uv run -m pytest
142
+ uv run ruff check . && uv run ty check
143
+ ```
144
+
145
+ ## Currency
146
+
147
+ Estimates use **USD** by default. Set `--currency CHF` (or `EUR`, for example) on
148
+ `estimate` and `prices` to request that currency directly from Azure:
149
+
150
+ ```sh
151
+ bicep-cost estimate main.bicep --currency CHF
152
+ bicep-cost prices --service "Key Vault" --currency EUR
153
+ ```
154
+
155
+ For estimates the precedence is `--currency`, then `[meta] cost_currency` in the
156
+ usage TOML, then USD. Codes are case insensitive. Reports and budget limits use
157
+ the selected currency; cached prices are isolated by currency. Manual
158
+ `monthly_cost` values must use the same currency as the estimate; conflicting
159
+ usage-file metadata is rejected. No exchange-rate conversion is performed.
160
+ Diffs require both reports to use the same currency.
161
+
162
+ Azure's non-USD retail rates are budget estimates, not negotiated billing rates.
163
+ See [Microsoft's Retail Prices API currency documentation](https://learn.microsoft.com/rest/api/cost-management/retail-prices/azure-retail-prices).
164
+
165
+ ## Percentage cost caps in CI
166
+
167
+ Save a baseline estimate (using the same currency and usage assumptions), then
168
+ fail if the new monthly cost grows by more than 10 percent:
169
+
170
+ ```sh
171
+ bicep-cost estimate main.bicep --format json -o base.json
172
+ # After changing the template:
173
+ bicep-cost estimate main.bicep --baseline base.json --fail-increase-percent 10 --fail-above 1500
174
+ # Or compare two saved JSON reports:
175
+ bicep-cost diff base.json head.json --fail-increase-percent 10 --fail-above 1500
176
+ ```
177
+
178
+ Either exceeded cap returns **3**. Equality and decreases pass; any positive cost
179
+ from a zero baseline fails the relative cap. Thresholds must be finite and
180
+ nonnegative. The full report is written before a budget failure, including with
181
+ `--output`, so CI can always retain it. `--strict` failures still take precedence
182
+ (exit 2). Baseline reports must use the same currency; no conversion is applied.
183
+
184
+ ## Cost breakdown charts
185
+
186
+ Text and Markdown estimates include horizontal bars ordered from the most
187
+ expensive resource to the least. Each bar shows the monthly amount and share of
188
+ the known total. Text `--group-by type` or `--group-by module` aggregates both the
189
+ chart and table. Zero-cost resources do not get bars; unresolved and unsupported
190
+ resources remain listed below, since their costs are not included in the total.
191
+ Charts are plain text for readable CI logs and Markdown PR comments, and are
192
+ written even when a budget cap fails. JSON and CSV remain machine-readable.
193
+
194
+ ## Automatic releases
195
+
196
+ Like [bmsui](https://github.com/bmsuisse/bmsui/blob/main/.github/workflows/release.yml),
197
+ main pushes run reusable CI, detect a new package version, publish to PyPI using
198
+ OIDC, and create a GitHub release with wheel/sdist assets. PR CI checks Ruff, ty,
199
+ tests, builds and an installed-wheel smoke test on Python 3.11 and 3.14.
200
+
201
+ Before the first merge, a PyPI maintainer must register a
202
+ [pending trusted publisher](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/):
203
+ project **bicep-cost**, owner **bmsuisse**, repository **bicep-cost**, workflow
204
+ **release.yml**, environment **pypi**. Use the same environment in GitHub;
205
+ restrict its deployment branches to `main`. No long-lived PyPI token is needed.
206
+
207
+ Bump `[project] version` in `pyproject.toml`, run `uv lock`, and merge the PR.
208
+ Main publishes that version after CI passes; an existing GitHub release is
209
+ skipped. The Release workflow can also be dispatched manually from main; its
210
+ **dry_run** input defaults to true, validating without publishing. Release jobs
211
+ are serialized. If publication succeeded but release creation failed, rerun the
212
+ workflow: existing PyPI files are skipped and GitHub release creation is retried.
213
+ Do not change files for a version already uploaded to PyPI; bump the version.
214
+
215
+ ## Azure account prices
216
+
217
+ Retail prices remain the default and require no login. To use negotiated
218
+ consumption rates through the logged-in Azure CLI user:
219
+
220
+ ```sh
221
+ az login
222
+ bicep-cost estimate main.bicep --azure-prices --subscription-id YOUR_SUBSCRIPTION_ID
223
+ # Persist a private snapshot while estimating:
224
+ bicep-cost estimate main.bicep --azure-prices --save-price-sheet account-prices.json
225
+ # Or download separately:
226
+ bicep-cost price-sheet --subscription-id YOUR_SUBSCRIPTION_ID -o account-prices.json
227
+ # Reuse without Azure login:
228
+ bicep-cost estimate main.bicep --price-sheet account-prices.json
229
+ ```
230
+
231
+ Without a subscription argument, downloads use the CLI's current subscription.
232
+ The read-only [Consumption Price Sheet API](https://learn.microsoft.com/rest/api/consumption/price-sheet/get)
233
+ requires access to the subscription's pricing and a supported billing agreement.
234
+ An ordinary Azure login alone does not guarantee access. Unsupported agreements,
235
+ missing sheets and denied access produce an error. Within a successfully loaded
236
+ price sheet, missing meter rates fall back to retail prices with warnings.
237
+
238
+ For MCA/MPA accounts, or when subscription downloads are unavailable, download
239
+ and extract the current consumption price-sheet CSV from
240
+ [Cost Management + Billing](https://learn.microsoft.com/azure/cost-management-billing/manage/ea-pricing),
241
+ then use `--price-sheet prices.csv`. The importer accepts case-insensitive column
242
+ names `meterId`, optional `skuId`, `currencyCode` or `currency`, `unitOfMeasure`,
243
+ `unitPrice`, optional `tierMinimumUnits` and `priceType`. It uses **price currency**,
244
+ not `billingCurrency`; reservations and savings-plan rows are excluded. Import a
245
+ single current price period: conflicting duplicate rates are rejected. Legacy
246
+ CSV layouts must be converted to these columns first. Included-quantity grants
247
+ are rejected because the sheet does not supply enough billing-scope information
248
+ to allocate them safely.
249
+
250
+ Account mode discovers meter identities through the Retail API, then replaces
251
+ rates by meter/SKU ID, normalizing compatible block units (for example 100 Hours
252
+ to 1 Hour). Missing account meter rates use retail prices with an explicit pricing
253
+ warning in text, Markdown, and JSON reports; `--strict` does not block this
254
+ fallback. Resources with no retail meter still become unresolved. Manual `[discounts]` are ignored because account rates
255
+ already include discounts. Reports record the sheet's origin and snapshot time.
256
+ Explicit `--currency` and usage-file currency must match the sheet; by default
257
+ its currency is selected. No exchange-rate conversion is performed.
258
+
259
+ Snapshots contain negotiated rates, not Azure tokens, and are saved atomically
260
+ with owner-only permissions. Keep them private and out of source control. Saved
261
+ sheets can be used without login; completely offline estimates also need the
262
+ Retail API metadata in the normal price cache. Use `--price-sheet FILE --offline`
263
+ for that; `--azure-prices` always downloads fresh rates. Snapshots are not refreshed
264
+ automatically, so download a new sheet when your agreement or billing period changes.
@@ -0,0 +1 @@
1
+ """Estimate Azure costs from Bicep / ARM templates."""