Cost Awareness and Governance for Python IaC

Infrastructure written in Python is infrastructure that can be priced before it exists. A Pulumi preview or a CDKTF plan is a complete, machine-readable description of every resource a merge is about to create, resize, or destroy — and that description is enough to compute most of the bill it will produce. This topic, part of Python IaC fundamentals and strategy, covers how to turn a preview into a number, how to fail a pipeline on that number, how to tag resources so the spend can be attributed afterwards, and — just as important — where estimation is structurally unable to help you.

Problem Framing

The default feedback loop on cloud spend is catastrophically slow. An engineer merges a change on the third of the month. The resource is billed hourly from that afternoon. The line item lands in a cost report some time after the first of the following month, is noticed in a finance review a week after that, and reaches the team that caused it around day forty-five. By then nobody remembers the pull request, the resource has production data on it, three other services resolve its DNS name, and removing it is a migration project rather than a one-line revert.

Compare that with the same information delivered at review time. The change is still text. The author is still holding the context. The reviewer is already reading the diff. Reverting costs one click. Every hour that passes between "this line was written" and "this line costs money" multiplies the effort required to act on it, and the multiplier is not linear — it steps up sharply the moment the resource starts holding state.

The diff is also where the signal is densest. Changing db.r6g.large to db.r6g.4xlarge is a single token in a Python file and roughly an eight-fold change in the hourly rate of that instance. Adding multi_az=True doubles it again. A reviewer looking at correctness will approve both without comment, because nothing about the text suggests a price. Printing the delta next to the line changes the nature of the review: the question stops being "is this valid?" and becomes "is this worth it?", which is the question that was always intended.

The reframe that makes the rest of this topic coherent is to treat cost as a non-functional requirement in the same family as latency, availability, and security posture. Those all get automated checks that run on every change, a threshold, an owner, and a review cadence. Cost gets a monthly spreadsheet. There is no principled reason for the difference — the machinery you already run for security and compliance basics is exactly the machinery a cost check needs.

One boundary has to be stated up front, because ignoring it is how cost tooling loses credibility. A preview-time estimate gives you committed cost: what the account owes before a single request arrives. It does not give you total cost. That is a real limitation, and it is also less damaging than it sounds — committed cost is where most avoidable waste lives. Oversized instances, idle non-production environments, orphaned EBS volumes, forgotten NAT gateways, and databases running Multi-AZ in a sandbox account are all fully visible in a plan. Usage-driven waste is real too, but it is a different discipline with a different toolchain.

Prerequisites

  • Python 3.9+ with pulumi>=3.0 (or cdktf>=0.20) and boto3>=1.34 pinned in the same environment your pipeline uses
  • A CI identity that can run a preview with read-only cloud credentials — a preview needs to refresh state, not mutate it
  • pricing:GetProducts on the AWS Price List Query API, plus ce:GetCostAndUsage if you also reconcile against actual spend
  • At least one cost allocation tag key activated in the billing account, and the knowledge of which keys those are
  • An existing tag convention, even an informal one — you cannot enforce a standard you have not written down
# CLI: confirm the preview identity is read-only and the pricing endpoint is reachable
aws sts get-caller-identity --query 'Arn'
aws pricing describe-services --region us-east-1 --service-code AmazonRDS \
  --query 'Services[0].AttributeNames' --output text | tr '\t' '\n' | head -20
# Provider note: the Price List Query API is only served from us-east-1,
# eu-central-1 and ap-south-1 — the endpoint region is NOT the priced region.

Where a Cost Signal Comes From

There are exactly four places a cost number can come from, and each one is authoritative about something different. Mixing them up is the most common reason an estimate is confidently wrong.

Cost signal sources Cost signal sources: Deploy-time cost signal with 4 facets. Deploy-time cost signal Preview graph declared types and sizes Pricing API list rate per SKU Allocation tags who the spend belongs to Billing history actual usage, 24h late
Four inputs feed a cost signal; only the first two exist before an apply runs.

The plan or preview resource graph

This is the only source that knows about a change before it happens. A Pulumi preview digest contains one step per resource, each carrying the operation (create, update, replace, delete, same), the URN, the provider reference, and the fully resolved input properties. A Terraform plan rendered by terraform show -json contains a resource_changes array with change.actions and change.after. Either structure gives you resource type, region, declared size, and tags.

What it does not give you is anything whose value is only known after the apply. Terraform marks these in change.after_unknown; Pulumi shows them as computed placeholders in the digest. If an instance type is read from a stack reference or a parameter store lookup, the preview may legitimately not know it. Deletes are also asymmetric: a delete step carries an oldState and no newState, so an estimator that only reads newState silently ignores every saving.

Provider pricing APIs

The AWS Price List Query API answers "what is the list rate for this SKU in this region". It is filtered by attribute — instanceType, regionCode, databaseEngine, deploymentOption — and returns product records whose terms.OnDemand map contains the price dimensions. Azure exposes an equivalent anonymous retail price service filtered by serviceName, armSkuName, and armRegionName; Google Cloud exposes a billing catalogue of SKUs with tiered rates keyed by service ID.

Their shared accuracy limit is that they return list price. Your account almost certainly does not pay list: enterprise discounts, savings plans, reserved instances, and committed use discounts all apply after the fact, and none of them are visible from a pricing API. The honest handling is to measure your effective-rate ratio once against a real invoice, record it as a documented constant with the date it was measured, and present the list-price number as an upper bound rather than pretending it is your price.

Cost-allocation tags and the billing API

Tags plus Cost Explorer are the only source that tells you what actually happened and who owns it. Group GetCostAndUsage by a tag key and you get spend per owner, per environment, per cost centre. This is ground truth, with three caveats that catch everyone once: a tag key must be activated as a cost allocation tag in the billing account before it can be grouped on; activation is forward-only and never backfills; and the data lags real usage by up to twenty-four hours. Resources that carry no value for the key appear under a group key of owner$ with an empty suffix.

What estimation cannot know

Be explicit about the blind spots, in the report itself, every time. The categories below are not edge cases — for a request-driven service they are frequently the majority of the bill.

Confidence in a preview-time price Confidence in a preview-time price: Instance hours, Provisioned storage, Managed service base fee, Autoscaled capacity, Data transfer, Per-request charges. Instance hours 92% — size is declared Provisioned storage 88% — GB is declared Managed service base fee 85% — fixed per hour Autoscaled capacity 35% — min/max only Data transfer 18% — traffic unknown Per-request charges 12% — volume unknown
Static estimation is strong on declared capacity and near-blind on usage-driven charges.

Data transfer. Cross-availability-zone traffic, NAT gateway processing charges, and internet egress are all priced per gigabyte, and a plan contains zero gigabytes. Adding a NAT gateway is priceable (an hourly rate); the traffic through it is not.

Request volume. Lambda invocations, DynamoDB on-demand read and write units, S3 GET/PUT requests, and API Gateway calls are all usage-metered. A plan that adds a DynamoDB table with billing_mode="PAY_PER_REQUEST" adds a resource whose committed cost is genuinely zero and whose real cost is entirely unknown.

Autoscaling. An autoscaling group with min_size=2, max_size=40 has a committed cost of two instances and a ceiling of forty. Reporting only the minimum understates a real risk; reporting only the maximum makes the gate scream on every change until people mute it. Report both, and gate on the minimum while surfacing the ceiling.

Second-order amplification. A new chatty service raises CloudWatch Logs ingestion, adds metrics, and inflates a downstream cache. None of that appears in the plan for the new service.

The practical resolution is to emit three numbers from every estimate rather than one: committed monthly cost, worst-case ceiling, and a count of resources the estimator could not price at all. The third number is the one that protects the tool's credibility — a resource that silently prices as zero teaches people the estimate is a lie.

Tagging Standards as the Foundation of Attribution

Every attribution question — which team, which product, which environment — reduces to a tag lookup. If tagging is best-effort, attribution is best-effort, and a governance programme built on it will spend its meetings arguing about whose line item is whose instead of deciding anything. Treat the tag standard as a schema with required keys, allowed values, and a format, and enforce it in layers.

Tag enforcement stack Tag enforcement stack: layered from Tag schema down to Billing reconciliation. Tag schema a dataclass with required keys and allowed values Provider default_tags applied to every resource that provider creates Stack transformation fills the gaps the provider cannot reach CrossGuard policy fails preview on a missing or malformed key Billing reconciliation measures what still arrives untagged
Each layer catches what the layer above it missed; the bottom layer measures the leak.
# tagging.py — one typed source of truth for the tag standard
# CLI: python -c "import tagging; print(tagging.StandardTags.from_env().to_dict())"
from __future__ import annotations

import os
import re
from dataclasses import dataclass

_COST_CENTRE = re.compile(r"^CC-\d{4}$")
_ENVIRONMENTS = frozenset({"dev", "staging", "prod"})


@dataclass(frozen=True)
class StandardTags:
    owner: str          # the team's on-call rotation name, not a person
    cost_center: str    # CC-#### — must exist in the finance chart of accounts
    env: str
    managed_by: str = "pulumi"

    def __post_init__(self) -> None:
        if not _COST_CENTRE.match(self.cost_center):
            raise ValueError(f"cost_center {self.cost_center!r} must match CC-####")
        if self.env not in _ENVIRONMENTS:
            raise ValueError(f"env {self.env!r} must be one of {sorted(_ENVIRONMENTS)}")

    @classmethod
    def from_env(cls) -> "StandardTags":
        return cls(owner=os.environ["TAG_OWNER"],
                   cost_center=os.environ["TAG_COST_CENTER"],
                   env=os.environ["TAG_ENV"])

    def to_dict(self) -> dict[str, str]:
        return {"owner": self.owner, "cost-center": self.cost_center,
                "env": self.env, "managed-by": self.managed_by}

The cheapest enforcement layer is the provider itself. An explicitly constructed AWS provider with default_tags stamps every resource it creates, with no per-resource code:

# providers.py — default tags at the provider level
# CLI: pulumi up --stack prod
import pulumi_aws as aws

from tagging import StandardTags

std = StandardTags.from_env()

tagged_aws = aws.Provider(
    "tagged-aws",
    region="eu-west-1",
    default_tags=aws.ProviderDefaultTagsArgs(tags=std.to_dict()),
)
# Provider note: resource-level `tags` are merged over these, and an explicit
# value for the same key WINS. Setting the same key in both places on provider
# v4 produced a perpetual diff; v5+ resolves it in favour of the resource.
# State implication: adding default_tags rewrites the tag map of every managed
# resource, so the first preview after adopting it shows a large update batch.

Two gaps remain. A provider's default tags only reach resources created through that provider instance, so a component that constructs its own provider bypasses them, and resource types that do not support tags at all are simply skipped. A stack transformation closes the first gap by running over every resource registration in the program regardless of provider:

# transforms.py — belt-and-braces tagging for resources the provider missed
# CLI: pulumi preview --stack prod
from typing import Optional

import pulumi

from tagging import StandardTags

_UNTAGGABLE_PREFIXES = ("pulumi:", "aws:iam/rolePolicyAttachment:")
_STD = StandardTags.from_env().to_dict()


def enforce_tags(
    args: pulumi.ResourceTransformationArgs,
) -> Optional[pulumi.ResourceTransformationResult]:
    if args.type_.startswith(_UNTAGGABLE_PREFIXES):
        return None
    if "tags" not in args.props:
        return None                     # type genuinely has no tags property
    merged = {**_STD, **(args.props.get("tags") or {})}
    return pulumi.ResourceTransformationResult(
        props={**args.props, "tags": merged}, opts=args.opts)


pulumi.runtime.register_stack_transformation(enforce_tags)
# State implication: transformations run before the diff, so a newly added tag
# shows as an update on existing resources, not a replacement.

Above the transformation sits a policy check that turns a missing tag into a failed preview rather than a silently untagged resource. That belongs with your other Pulumi policy as code rules, where a ResourceValidationPolicy at EnforcementLevel.MANDATORY reports a violation for any billable type whose tags map lacks a required key. Below everything sits reconciliation: query the billing API monthly, group by the tag key, and track the untagged fraction as a number that has to trend down. Anything above a couple of per cent means one of the upper layers has a hole in it.

Some resources genuinely cannot be tagged, and no amount of policy will change that. For those, attribution falls back to the account or subscription boundary — which is why one account per team or per environment remains the strongest attribution primitive available, stronger than any tag standard.

Step-by-Step: A Cost Gate from a Pulumi Preview

The estimator is a small, boring, entirely testable program: read a preview digest, produce a typed change list, resolve a rate for each change, sum it, compare against a budget, exit with a code. Nothing about it needs to be clever, and everything about it needs to be honest about what it skipped.

Estimator pipeline Estimator pipeline: preview JSON then parse steps then resolve rates then aggregate delta then threshold gate preview --json engine events parse steps typed change list resolve rates pricing API + cache aggregate delta monthly USD threshold gate exit 0, 1 or 2
The estimator is a pure function from a preview digest to a monthly delta and an exit code.

1. Produce a machine-readable preview

# CLI: capture the preview digest without applying anything
pulumi preview --stack prod --json > preview.json
# State implication: preview refreshes nothing by default; add --refresh if you
# need the estimate measured against real infrastructure rather than last state.

# CDKTF / Terraform equivalent
cdktf synth
terraform -chdir=cdktf.out/stacks/prod plan -out=tfplan
terraform -chdir=cdktf.out/stacks/prod show -json tfplan > plan.json

The Pulumi digest is a single JSON object whose steps array is what matters:

{
  "steps": [
    {
      "op": "create",
      "urn": "urn:pulumi:prod::checkout::aws:rds/instance:Instance::orders-db",
      "newState": {
        "type": "aws:rds/instance:Instance",
        "inputs": {
          "instanceClass": "db.r6g.2xlarge",
          "engine": "postgres",
          "allocatedStorage": 500,
          "storageType": "gp3",
          "multiAz": true,
          "tags": {"owner": "payments", "cost-center": "CC-4471", "env": "prod"}
        }
      }
    }
  ],
  "changeSummary": {"create": 1, "same": 42},
  "duration": 0
}

Note that the input keys are the SDK's camelCase names, not the Terraform snake_case ones. A Terraform plan for the same resource nests the equivalent values under resource_changes[].change.after with instance_class and multi_az. Any estimator that wants to serve both toolchains needs a normalisation step; do not try to write one parser that pretends the two shapes are the same.

2. Parse the digest into typed change records

# cost/preview.py — normalise a Pulumi digest into typed change records
# CLI: python -m cost.preview preview.json
from __future__ import annotations

import json
from dataclasses import dataclass, field
from typing import Any, Literal

Op = Literal["create", "update", "replace", "delete"]
BILLABLE_OPS: frozenset[str] = frozenset({"create", "update", "replace", "delete"})


@dataclass(frozen=True)
class ResourceChange:
    urn: str
    op: Op
    resource_type: str
    inputs: dict[str, Any] = field(default_factory=dict)
    prior: dict[str, Any] = field(default_factory=dict)

    @property
    def name(self) -> str:
        return self.urn.rsplit("::", 1)[-1]

    @property
    def sign(self) -> int:
        """+1 adds monthly cost, -1 removes it."""
        return -1 if self.op == "delete" else 1

    @property
    def tags(self) -> dict[str, str]:
        return {k: str(v) for k, v in (self.inputs.get("tags") or {}).items()}


def load_preview(path: str) -> list[ResourceChange]:
    with open(path, encoding="utf-8") as handle:
        digest = json.load(handle)
    changes: list[ResourceChange] = []
    for step in digest.get("steps", []):
        op = step.get("op", "")
        if op not in BILLABLE_OPS:
            continue                       # "same" steps cost nothing new
        new_state = step.get("newState") or {}
        old_state = step.get("oldState") or {}
        # A delete step carries only oldState — read it or you lose every saving.
        state = new_state or old_state
        changes.append(ResourceChange(
            urn=step["urn"], op=op,                       # type: ignore[arg-type]
            resource_type=state.get("type", ""),
            inputs=state.get("inputs", {}),
            prior=old_state.get("inputs", {}),
        ))
    return changes

3. Resolve unit rates

Rate lookup is the slow, failure-prone part, so isolate it behind a cache and make a miss loud rather than free.

# cost/rates.py — list-price lookup against the AWS Price List Query API
# CLI: python -m cost.rates db.r6g.2xlarge PostgreSQL eu-west-1
from __future__ import annotations

import json
from decimal import Decimal
from functools import lru_cache

import boto3

HOURS_PER_MONTH = Decimal(730)
# Measured against the March invoice: we pay 0.78 of list after savings plans.
EFFECTIVE_RATE_FACTOR = Decimal("0.78")

_pricing = boto3.client("pricing", region_name="us-east-1")


class Unpriceable(Exception):
    """No list rate could be resolved for a resource."""


@lru_cache(maxsize=512)
def rds_hourly_usd(instance_class: str, engine: str,
                   region_code: str, multi_az: bool) -> Decimal:
    response = _pricing.get_products(
        ServiceCode="AmazonRDS",
        Filters=[
            {"Type": "TERM_MATCH", "Field": "instanceType", "Value": instance_class},
            {"Type": "TERM_MATCH", "Field": "databaseEngine", "Value": engine},
            {"Type": "TERM_MATCH", "Field": "regionCode", "Value": region_code},
            {"Type": "TERM_MATCH", "Field": "deploymentOption",
             "Value": "Multi-AZ" if multi_az else "Single-AZ"},
        ],
        MaxResults=1,
    )
    if not response["PriceList"]:
        raise Unpriceable(
            f"no on-demand SKU for {instance_class}/{engine} in {region_code}")
    product = json.loads(response["PriceList"][0])
    term = next(iter(product["terms"]["OnDemand"].values()))
    dimension = next(iter(term["priceDimensions"].values()))
    return Decimal(dimension["pricePerUnit"]["USD"])


def rds_monthly_usd(inputs: dict, region_code: str) -> Decimal:
    engine_map = {"postgres": "PostgreSQL", "mysql": "MySQL", "mariadb": "MariaDB"}
    engine = engine_map.get(inputs.get("engine", ""), "")
    if not engine:
        raise Unpriceable(f"unmapped engine {inputs.get('engine')!r}")
    hourly = rds_hourly_usd(inputs["instanceClass"], engine, region_code,
                            bool(inputs.get("multiAz")))
    return hourly * HOURS_PER_MONTH * EFFECTIVE_RATE_FACTOR

The engine_map is not incidental. Pulumi's engine="postgres" and the pricing API's databaseEngine="PostgreSQL" are different vocabularies for the same thing, and every provider has a handful of these mismatches. Fail loudly on an unmapped value rather than defaulting to zero.

4. Aggregate the delta and apply thresholds

Budget threshold outcomes Budget threshold outcomes: choose among 4 options. Monthly delta from thischange under Auto-approve,annotate the pullrequest over soft Require a named costreviewer over hard Fail the pipeline,exit 2 unpriced Warn and record thegap
Four outcomes, not two: an unpriceable resource must be visible rather than silently free.
# cost/gate.py — turn an estimate into a pipeline outcome
# CLI: python -m cost.gate preview.json --soft 50 --hard 250
from __future__ import annotations

import sys
from dataclasses import dataclass
from decimal import Decimal

from cost.preview import ResourceChange, load_preview
from cost.rates import Unpriceable, rds_monthly_usd

EXIT_OK, EXIT_SOFT, EXIT_HARD = 0, 1, 2


@dataclass(frozen=True)
class Estimate:
    committed_usd: Decimal
    unpriced: tuple[str, ...]

    def verdict(self, soft: Decimal, hard: Decimal) -> int:
        if self.committed_usd >= hard:
            return EXIT_HARD
        if self.committed_usd >= soft or self.unpriced:
            return EXIT_SOFT
        return EXIT_OK


def estimate(changes: list[ResourceChange], region_code: str) -> Estimate:
    total = Decimal(0)
    unpriced: list[str] = []
    for change in changes:
        try:
            if change.resource_type == "aws:rds/instance:Instance":
                total += change.sign * rds_monthly_usd(change.inputs, region_code)
            else:
                raise Unpriceable(f"no rule for {change.resource_type}")
        except Unpriceable as exc:
            unpriced.append(f"{change.name}: {exc}")
    return Estimate(committed_usd=total, unpriced=tuple(unpriced))


def main(path: str, soft: Decimal, hard: Decimal, region_code: str) -> int:
    result = estimate(load_preview(path), region_code)
    print(f"committed monthly delta: ${result.committed_usd:,.2f}")
    for line in result.unpriced:
        print(f"  UNPRICED  {line}")
    return result.verdict(soft, hard)


if __name__ == "__main__":
    sys.exit(main(sys.argv[1], Decimal("50"), Decimal("250"), "eu-west-1"))

Three exit codes, not two, is the design decision that keeps the gate usable. Exit 2 blocks. Exit 1 marks the pull request as needing a named cost reviewer without stopping work. Exit 0 passes quietly. A binary gate is either so loose it never fires or so tight that people route around it, and a routed-around gate is worse than none because it produces false confidence. The full mechanics of building and tuning this check are covered in building a cost guardrail check in Python.

5. Wire it into the pipeline

# .github/workflows/cost-gate.yml
# CLI: gh workflow run cost-gate
name: cost-gate
on: [pull_request]
permissions:
  id-token: write        # OIDC assume-role for a READ-ONLY preview identity
  contents: read
  pull-requests: write   # so the estimate can be posted as a review comment
jobs:
  estimate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pulumi/actions@v6
        with:
          command: preview
          stack-name: prod
          args: --json
      - name: Score the preview
        run: python -m cost.gate preview.json --soft 50 --hard 250

Run the estimator on a preview taken against the target stack, not a scratch one, or every resource will show as create and the delta will be the cost of the entire environment. Cache the resolved rates as a build artifact keyed by region and date so a pricing API outage degrades to a stale number rather than a red pipeline. The end-to-end wiring, including the pull-request annotation, is worked through in estimating infrastructure cost in Python IaC pipelines.

Ownership Metadata and Change Audit Trails

Attribution answers "which team", but the question that actually gets asked in a cost review is "who added this, and when, and why". That requires metadata written at deploy time, because it cannot be reconstructed afterwards from the cloud API alone.

Ownership trail Ownership trail: Pull request → CI job → Resource tags → Audit index. Pull request CI job Resource tags Audit index commit SHA stamp owner export URN map answer who
A billing line traced back to the commit that created it, through tags written at deploy time.
# audit.py — stamp provenance onto every resource and export an ownership index
# CLI: pulumi up --stack prod
from __future__ import annotations

import os

import pulumi

from tagging import StandardTags

_std = StandardTags.from_env().to_dict()
_provenance = {
    # Tag values allow letters, digits, spaces and + - = . _ : / @ — a commit
    # SHA and a branch name are both safe; free-form descriptions are not.
    "git-sha": os.environ.get("GITHUB_SHA", "local")[:12],
    "change-request": os.environ.get("GITHUB_REF_NAME", "local"),
    "deployed-by": os.environ.get("GITHUB_ACTOR", os.environ.get("USER", "unknown")),
}
# Never put a person's email or any secret here: tags are reproduced verbatim in
# the Cost and Usage Report, which is readable far more widely than the repo.

pulumi.export("ownership", {**_std, **_provenance})
# State implication: the export is recorded in the stack's checkpoint, so
# `pulumi stack output ownership` answers "who deployed this version" offline.

Pair the tags with the deployment history the engine already keeps. pulumi stack history --json returns one record per update with version, kind, startTime, result, and an environment map that includes git.head, git.headName, and the committer — enough to bind a billing month to a set of commits without any extra bookkeeping:

# CLI: list the last ten updates with the commit that produced each one
pulumi stack history --stack prod --json \
  | jq -r '.[:10][] | [.version, .kind, .startTime, .environment["git.head"][0:12]] | @tsv'

# Correlate with the control-plane trail: the deploy role's session name should
# carry the run id, so CloudTrail can answer "which pipeline run made this call".
aws cloudtrail lookup-events --lookup-attributes \
  AttributeKey=EventName,AttributeValue=CreateDBInstance \
  --query 'Events[].{t:EventTime,u:Username}' --output table

Archive the preview digest and the estimate as CI artifacts keyed by commit SHA. When someone asks in November why October cost more, the answer is a diff of two archived estimates rather than an afternoon of archaeology. The full pattern — including what to store, for how long, and how to query it — is covered in tracking IaC change ownership and audit trails.

The Governance Loop

Detection without attribution produces a number nobody owns. Attribution without enforcement produces a report nobody reads. Enforcement without review produces thresholds that calcify until engineers learn to add a skip annotation. The four stages only work as a loop.

Governance loop Governance loop: Detect → Attribute → Enforce → Review → repeat. Detect Attribute Enforce Review
Each turn feeds the next: reviews move thresholds, thresholds change what gets detected.

Detect runs on two cadences. Per change, the preview gate described above. Per week, a sweep for resources that exist and earn nothing — unattached volumes, idle load balancers, elastic IPs with no association, snapshots older than the retention policy. That second sweep is the one that finds real money, and it is covered in auditing unused cloud resources with Python.

Attribute maps every detected item to an owner through the tag standard, falling back to the account boundary where tags are absent or impossible. Publish the untagged fraction alongside the spend; a rising untagged fraction invalidates every other number in the report.

Enforce applies the thresholds and the policy pack. Keep the escalation path explicit and short: a soft breach needs one named reviewer, a hard breach needs an exception recorded in the repository with an expiry date. An exception without an expiry date is a permanent hole with a comment on it.

Review happens monthly and has one job — change something. Move a threshold that fires too often, retire a check that has never fired, promote a warning to a block, delete a tag key nobody queries. A governance loop whose review stage produces no changes has stopped being a loop and become a ritual.

Verification

Verify the estimator the way you would verify any other piece of production code: with fixtures and asserted numbers, not by eyeballing a pipeline log.

# tests/test_gate.py — pin the estimator against a recorded preview digest
# CLI: pytest tests/test_gate.py -q
from decimal import Decimal

import pytest

from cost.gate import EXIT_HARD, EXIT_OK, Estimate, estimate
from cost.preview import load_preview


def test_delete_reduces_the_delta() -> None:
    changes = load_preview("tests/fixtures/preview_delete_rds.json")
    assert changes[0].sign == -1


def test_unpriced_resources_force_a_soft_failure() -> None:
    result = Estimate(committed_usd=Decimal("10"), unpriced=("gw: no rule",))
    assert result.verdict(Decimal("50"), Decimal("250")) != EXIT_OK


@pytest.mark.parametrize("total,expected", [("249.99", 1), ("250.00", EXIT_HARD)])
def test_hard_threshold_is_inclusive(total: str, expected: int) -> None:
    result = Estimate(committed_usd=Decimal(total), unpriced=())
    assert result.verdict(Decimal("50"), Decimal("250")) == expected

Then verify the model itself, once a month, against reality. Pick a stack whose traffic did not change materially and compare the predicted committed cost with the invoiced amount for the same resources. A stable ratio — even a ratio of 0.8 or 1.3 — means the model is usable and the constant needs updating. A ratio that wanders means a whole category of resource is missing a pricing rule.

# CLI: what fraction of last month's spend arrived without an owner tag?
aws ce get-cost-and-usage --time-period Start=2026-06-01,End=2026-07-01 \
  --granularity MONTHLY --metrics UnblendedCost \
  --group-by Type=TAG,Key=owner \
  --query 'ResultsByTime[0].Groups[?Keys[0]==`owner$`].Metrics.UnblendedCost.Amount'

Troubleshooting

Unpriceable: no on-demand SKU for db.r6g.2xlarge/postgres in eu-west-1. The filter vocabulary does not match. databaseEngine expects PostgreSQL, not the provider's postgres; regionCode expects eu-west-1, while the older location field expects EU (Ireland). Print the attribute values with aws pricing get-attribute-values --service-code AmazonRDS --attribute-name databaseEngine before guessing.

botocore.exceptions.EndpointConnectionError when calling the pricing client. There is no pricing endpoint in most regions. Construct the client with region_name="us-east-1" (or eu-central-1 / ap-south-1) regardless of which region you are pricing — the endpoint region and the priced region are unrelated.

Cost Explorer returns groups but every key is owner$. The tag key exists on resources but was never activated as a cost allocation tag, or was activated after the period you are querying. Activation is forward-only; there is no backfill, so a key activated today produces nothing for last month no matter how long the resources have carried it.

The estimate is $0.00 on a change that obviously costs money. Either the preview was taken against a stack where every step is same, or every resource type in the diff fell through to the unpriced bucket. This is exactly why the unpriced list is printed and why a non-empty list forces a soft failure — a silent zero is indistinguishable from a correct zero.

Every resource shows an update on the first preview after adopting default tags. Expected. The tag map of every managed resource is changing. Apply it once during a quiet window, and check the diff really is tag-only before approving, because a mass update batch is a good place for an unrelated change to hide.

error: unable to validate AWS credentials in the preview job. The read-only role assumed by the pipeline is missing the sts:AssumeRole trust or the OIDC subject claim does not match the pull-request ref. A preview still needs credentials — it reads provider state — so a credential-free "offline estimate" is not an option for anything that reads existing resources.

FAQ

How accurate is a preview-based cost estimate?

For committed, capacity-driven cost it is usually within a few per cent of the invoice once you apply a measured effective-rate factor. For usage-driven services it can be off by an order of magnitude in either direction, because the plan contains no traffic information at all. Present the two categories separately and never merge them into a single headline figure.

Do I need a dedicated cost tool, or is a Python script enough?

A two-hundred-line script that prices your five most expensive resource types will catch the large majority of cost regressions, because spend is heavily concentrated in a handful of types. Reach for a dedicated tool when you need broad multi-provider coverage or your own discount agreements modelled properly — not before, because a tool you do not understand produces numbers you cannot defend.

Should the cost gate block a merge or just comment?

Block above a hard threshold that is large enough to be genuinely unusual for your team — the number should be one that fires a few times a year, not weekly. Everything below that should annotate the pull request and require a named reviewer. A gate that fires constantly gets bypassed, and a bypassed gate teaches people that cost checks are theatre.

How do I attribute shared infrastructure like a NAT gateway?

You do not attribute it to one team; you split it. Tag the shared resource with a shared-service owner, then allocate its cost by a defensible driver — bytes processed per subnet, or an even split across the teams in the account — and publish the allocation rule where people can argue with it. The argument about the rule is far cheaper than a monthly argument about the invoice.

How often do pricing rates change, and how should I cache them?

List prices change rarely — a handful of times a year per service, almost always downwards — so a cache with a seven-day time-to-live is comfortable. Stamp every estimate with the date its rates were fetched, so a number quoted in a review can be reproduced later. On a cache miss with the pricing API unavailable, prefer a stale rate with a warning over a failed pipeline.

What should happen to a resource the estimator cannot price?

It must appear by name in the output and force a soft failure. Resources that fall through to zero are the single fastest way to destroy trust in a cost gate, because the first time someone finds a $4,000 line item the tool reported as free, they stop reading the tool. Over time the unpriced list becomes the backlog for new pricing rules.