CI/CD Cost Guardrails
Every other stage in this discipline reacts to spend that has already happened: an anomaly model flags a spike after the bill accrues, a budget webhook fires after a threshold is breached. CI/CD cost guardrails are the one preventive control — they price a terraform plan before it ever applies, post the delta as a pull-request comment, and fail the build if the projected monthly cost increase exceeds a policy threshold. The engineering problem is not detecting an anomaly in historical data; it is turning a terraform show -json plan into a priced delta in the seconds a CI job has before a reviewer approves the merge. This page covers the shift-left enforcement stage of Cloud Cost Anomaly Detection & Budget Automation: the plan-JSON parsing model, a small local rate map standing in for a pricing API, threshold policy evaluation, and the GitHub check-run integration that turns a Python exit code into a merge-blocking status.
Architecture Context & Data-Flow Position
Cost guardrails sit upstream of every other control in cost anomaly detection and budget automation. Building Cost Anomaly Detection Models and Budget Alert Automation with Webhooks both operate on realized billing data — they are reactive, running against yesterday’s or last month’s invoice rows. CI/CD guardrails operate on a terraform plan, which is a proposal, not a fact: the engine estimates cost before terraform apply ever runs, at the one point in the delivery lifecycle where blocking a change costs nothing but review time. The guardrail pipeline runs on every pull request that touches infrastructure-as-code: a GitHub Actions job runs terraform plan and converts it to JSON, the guardrail engine prices every resource_change against a rate map, computes the monthly delta against the current baseline, posts a formatted comment back onto the PR, and sets a GitHub check-run status that GitHub’s branch-protection rules can require before merge. This is the same enforcement pattern used for policy compliance in Enforcing Tag Policies in Terraform — a plan-time gate rather than a post-hoc scan — applied to dollars instead of tags.
| Stage | Input | Output | Failure mode if skipped |
|---|---|---|---|
| 1. Estimate | terraform show -json plan file |
List of PricedResource (type, region, monthly cost) |
No cost visibility at review time |
| 2. Diff | Priced resources + stored baseline monthly cost | delta_usd, delta_pct, proposed total |
Reviewers can’t see whether a change grows or shrinks spend |
| 3. Policy | Delta + GuardrailPolicy thresholds |
Pass/fail verdict, markdown comment body | No objective bar; approval becomes subjective |
| 4. Gate | Verdict + comment body | PR comment + GitHub check-run success/failure |
Guardrail is advisory only, not merge-blocking |
Deeper treatment of the PR-facing surface lives in GitHub Actions Cost-Diff Reporting; the enforcement mechanics of stage 3 and 4 — how a plan actually gets blocked, not just annotated — are covered in Blocking Terraform Plans That Exceed Cost Budgets.
Core Implementation Patterns
1. Parsing terraform show -json
Terraform’s machine-readable plan output nests the information the guardrail needs inside resource_changes[]. Each entry carries an address, a type (e.g. aws_instance, aws_db_instance), a provider_name, and a change.actions array (["create"], ["update"], ["delete"], or ["no-op"]). The guardrail only cares about resources whose actions include create or whose after attributes differ materially from before on cost-relevant fields:
import json
def load_resource_changes(plan_path: str) -> list[dict]:
"""Load resource_changes from a `terraform show -json <planfile>` export."""
with open(plan_path, "r", encoding="utf-8") as fh:
plan = json.load(fh)
# resource_changes is flat regardless of module nesting depth
return plan.get("resource_changes", [])
Deletions are priced as a negative delta (removing an aws_instance reduces the baseline), and no-ops are skipped entirely — pricing a no-op resource would double-count spend already reflected in the baseline.
2. Pricing Lookup Against a Rate Map
A production system calls a pricing API (Infracost’s engine, or the AWS/GCP/Azure price lists directly); this reference implementation uses a small in-memory rate map keyed on (resource_type, instance_size) so the engine is runnable without network access or API keys. The lookup function degrades gracefully — resources with no rate-map entry are logged and priced at $0.00 rather than crashing the whole plan, because a partial estimate that ships is more useful than a CI job that hard-fails on an unmapped type:
RATE_MAP = {
("aws_instance", "t3.medium"): 30.37,
("aws_instance", "m5.xlarge"): 140.16,
("aws_db_instance", "db.r5.large"): 175.20,
("aws_nat_gateway", None): 32.40,
}
def price_lookup(resource_type: str, size: str | None) -> float:
return RATE_MAP.get((resource_type, size), RATE_MAP.get((resource_type, None), 0.0))
3. Computing the Delta vs. Baseline
The delta is always computed against a stored baseline — the last known-good monthly total for the workspace, not a re-derivation from the plan’s before values, because before only reflects resources Terraform is touching, not the whole environment. The baseline is persisted per-workspace (a small JSON or DynamoDB row keyed on terraform_workspace) and updated only after a successful apply, never on every PR run.
4. Threshold Policy and the GitHub Check-Run API
The policy step evaluates the computed delta against two independent caps — an absolute dollar ceiling and a percentage-of-baseline ceiling — and fails if either is exceeded, since a small workspace doubling in cost (200% increase, small dollar figure) and a large workspace growing 5% (small percentage, large dollar figure) are both worth a human look. The verdict maps directly onto the GitHub Checks API: a POST /repos/{owner}/{repo}/check-runs with conclusion: "success" or conclusion: "failure" and status: "completed", which GitHub branch protection can then require as a status check before the merge button unlocks — the same mechanical gate used for the plan-time tag check in Enforcing Tag Policies in Terraform.
import requests
def post_check_run(repo: str, sha: str, token: str, passed: bool, summary: str) -> None:
"""Create a completed GitHub check-run reflecting the guardrail verdict."""
resp = requests.post(
f"https://api.github.com/repos/{repo}/check-runs",
headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"},
json={
"name": "cost-guardrail",
"head_sha": sha,
"status": "completed",
"conclusion": "success" if passed else "failure",
"output": {"title": "Cost guardrail", "summary": summary},
},
timeout=15,
)
resp.raise_for_status()
Production-Grade Python Cost-Guardrail Engine
The module below is the self-contained engine a GitHub Actions job invokes after terraform show -json plan.out > plan.json. It loads resource_changes, prices each against the rate map, computes the monthly delta against a stored baseline, renders a markdown comment, evaluates the threshold policy, and exits non-zero when the plan breaches it — the exit code is what a workflow step turns into a failed job and a blocked merge. Dependencies: standard library only, plus requests for the optional comment-posting path.
import json
import logging
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("cicd.cost_guardrail")
# Minimal rate map standing in for a live pricing API (Infracost, AWS Price List API, etc).
# Keyed on (resource_type, instance_size); a None size is the catch-all for sizeless resources.
RATE_MAP: dict[tuple[str, Optional[str]], float] = {
("aws_instance", "t3.micro"): 7.59,
("aws_instance", "t3.medium"): 30.37,
("aws_instance", "m5.large"): 70.08,
("aws_instance", "m5.xlarge"): 140.16,
("aws_db_instance", "db.t3.medium"): 58.40,
("aws_db_instance", "db.r5.large"): 175.20,
("aws_nat_gateway", None): 32.40,
("aws_elasticache_cluster", "cache.m5.large"): 116.80,
}
@dataclass
class PricedResource:
"""One resource_change from the plan, priced at its monthly rate."""
address: str
resource_type: str
action: str # "create", "update", or "delete"
monthly_usd: float # positive for create, negative for delete, 0 for no-op/unmapped
@dataclass
class GuardrailPolicy:
"""Threshold policy: fail if EITHER the absolute or percentage cap is breached."""
max_delta_usd: float = 500.0
max_delta_pct: float = 20.0
def evaluate(self, delta_usd: float, baseline_usd: float) -> bool:
"""Returns True if the delta PASSES policy (does not breach either cap)."""
if delta_usd <= 0:
return True # cost reductions and neutral changes never fail the gate
pct = (delta_usd / baseline_usd * 100) if baseline_usd > 0 else float("inf")
breached = delta_usd > self.max_delta_usd or pct > self.max_delta_pct
if breached:
logger.warning(
"policy breach: delta=$%.2f (%.1f%%) exceeds cap $%.2f (%.1f%%)",
delta_usd, pct, self.max_delta_usd, self.max_delta_pct,
)
return not breached
@dataclass
class GuardrailResult:
"""Final verdict: the priced resources, the delta, and the pass/fail outcome."""
baseline_usd: float
proposed_usd: float
delta_usd: float
delta_pct: float
passed: bool
priced_resources: list[PricedResource] = field(default_factory=list)
def _extract_size(after: dict) -> Optional[str]:
"""Pull the instance-size-equivalent attribute a resource type prices on."""
for key in ("instance_class", "instance_type", "node_type", "cache_node_type"):
if key in after:
return after[key]
return None
def load_resource_changes(plan_path: str) -> list[dict]:
"""Load resource_changes from a `terraform show -json <planfile>` export."""
path = Path(plan_path)
if not path.exists():
raise FileNotFoundError(f"plan JSON not found: {plan_path}")
with path.open("r", encoding="utf-8") as fh:
plan = json.load(fh)
changes = plan.get("resource_changes", [])
logger.info("loaded %d resource_changes from %s", len(changes), plan_path)
return changes
def price_resource_change(change: dict) -> Optional[PricedResource]:
"""Price a single resource_change; returns None for no-ops (nothing to price)."""
actions = change.get("change", {}).get("actions", [])
if actions in (["no-op"], ["read"]):
return None
resource_type = change.get("type", "")
address = change.get("address", "")
after = change.get("change", {}).get("after") or {}
before = change.get("change", {}).get("before") or {}
size = _extract_size(after) or _extract_size(before)
rate = RATE_MAP.get((resource_type, size))
if rate is None:
rate = RATE_MAP.get((resource_type, None), 0.0)
if (resource_type, size) not in RATE_MAP and rate == 0.0:
logger.info("no rate mapped for %s (%s); pricing at $0.00", resource_type, size)
if actions == ["delete"]:
monthly = -rate
action = "delete"
elif actions == ["create"]:
monthly = rate
action = "create"
else: # update / replace: treat as create-equivalent delta, conservative upper bound
monthly = rate
action = "update"
return PricedResource(
address=address, resource_type=resource_type, action=action, monthly_usd=round(monthly, 2),
)
def price_plan(resource_changes: list[dict]) -> list[PricedResource]:
"""Price every actionable resource_change in the plan."""
priced = [r for r in (price_resource_change(c) for c in resource_changes) if r is not None]
logger.info("priced %d actionable resource(s)", len(priced))
return priced
def load_baseline(baseline_path: str) -> float:
"""Read the last known-good monthly total for this workspace; 0.0 on first run."""
path = Path(baseline_path)
if not path.exists():
logger.info("no baseline file at %s; treating baseline as $0.00", baseline_path)
return 0.0
with path.open("r", encoding="utf-8") as fh:
return float(json.load(fh).get("monthly_usd", 0.0))
def render_comment(result: GuardrailResult) -> str:
"""Render the PR comment body summarizing the estimate and verdict."""
verb = "PASSED" if result.passed else "FAILED"
lines = [
f"### Cost guardrail: {verb}",
"",
f"| | Monthly USD |",
f"|---|---|",
f"| Baseline | ${result.baseline_usd:,.2f} |",
f"| Proposed | ${result.proposed_usd:,.2f} |",
f"| Delta | ${result.delta_usd:,.2f} ({result.delta_pct:+.1f}%) |",
"",
"| Resource | Action | Monthly USD |",
"|---|---|---|",
]
for r in sorted(result.priced_resources, key=lambda p: -abs(p.monthly_usd))[:15]:
lines.append(f"| `{r.address}` | {r.action} | ${r.monthly_usd:,.2f} |")
return "\n".join(lines)
def run_guardrail(plan_path: str, baseline_path: str, policy: GuardrailPolicy) -> GuardrailResult:
"""End-to-end: load plan, price it, diff against baseline, evaluate policy."""
changes = load_resource_changes(plan_path)
priced = price_plan(changes)
baseline_usd = load_baseline(baseline_path)
delta_usd = round(sum(r.monthly_usd for r in priced), 2)
proposed_usd = round(baseline_usd + delta_usd, 2)
delta_pct = (delta_usd / baseline_usd * 100) if baseline_usd > 0 else (100.0 if delta_usd > 0 else 0.0)
passed = policy.evaluate(delta_usd, baseline_usd)
logger.info(
"guardrail verdict=%s baseline=$%.2f proposed=$%.2f delta=$%.2f (%.1f%%)",
"PASS" if passed else "FAIL", baseline_usd, proposed_usd, delta_usd, delta_pct,
)
return GuardrailResult(
baseline_usd=baseline_usd,
proposed_usd=proposed_usd,
delta_usd=delta_usd,
delta_pct=delta_pct,
passed=passed,
priced_resources=priced,
)
def main() -> int:
"""CLI entry point: run the guardrail and exit non-zero on policy breach."""
plan_path = sys.argv[1] if len(sys.argv) > 1 else "plan.json"
baseline_path = sys.argv[2] if len(sys.argv) > 2 else "baseline.json"
policy = GuardrailPolicy(max_delta_usd=500.0, max_delta_pct=20.0)
result = run_guardrail(plan_path, baseline_path, policy)
comment = render_comment(result)
print(comment) # captured by the CI step and posted as a PR comment
if not result.passed:
logger.error("cost guardrail failed policy; blocking merge")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
Schema Reference Table
| Plan source field | Normalized field | Type | Notes |
|---|---|---|---|
resource_changes[].address |
address |
string | Fully-qualified Terraform address, including module path |
resource_changes[].type |
resource_type |
string | Primary rate-map lookup key, e.g. aws_instance |
resource_changes[].change.actions |
action |
enum | create | update | delete | no-op; no-op/read are never priced |
resource_changes[].change.after.instance_type (or equivalent) |
size |
string (nullable) | Secondary rate-map key; extracted via _extract_size across type-specific attribute names |
computed: rate × direction |
monthly_usd |
decimal | Positive for create/update, negative for delete |
| stored workspace state | baseline_usd |
decimal | Last known-good monthly total; updated only after a successful apply |
computed: Σ monthly_usd |
delta_usd |
decimal | Net projected monthly change from this plan |
computed: delta_usd / baseline_usd |
delta_pct |
decimal | Percentage change vs. baseline; inf-guarded when baseline is 0.0 |
| policy config | max_delta_usd / max_delta_pct |
decimal | Independent caps; breach of either fails the gate |
Operational Considerations
- Baseline staleness. The baseline only updates after a successful
apply, so a stack of open PRs against the same workspace each diff against the same stale number — the second PR to merge will show a compounded, not incremental, delta on its next re-run. Re-price on every push to the PR branch, not just on open, so the comment stays current. - Rate-map drift. A hardcoded rate map goes stale within a billing cycle for On-Demand pricing that fluctuates by region and reserved-capacity mix. Refresh the map from a pricing API (AWS Price List API, GCP Cloud Billing Catalog API, or the Infracost pricing API) on a schedule — daily is sufficient — and cache the result; do not call a pricing API synchronously inside every PR’s CI run, which adds latency and a hard external dependency to the merge path.
- Unmapped resource types. New resource types (a fresh AWS service, a community provider) have no rate-map entry and price at
$0.00by design — the engine logs this vialogger.inforather than failing, but a silent$0.00for a genuinely expensive resource is a false negative. Alert when the “unmapped resource” log line rate exceeds a small threshold (e.g. more than 2 per week) so the rate map gets extended before it hides real cost. - GitHub API rate limits. The Checks API and PR-comment endpoints share the standard GitHub REST budget of 1,000 requests/hour per repository for a
GITHUB_TOKEN-authenticated Actions job. A guardrail that also updates the comment on every subsequent push (rather than posting a new one) stays well under this; posting a fresh comment on every commit does not. - Plan size and CI job time.
terraform show -jsonon a plan touching thousands of resources can produce tens of megabytes of JSON; parse it streaming or cap the resources rendered in the comment table (the engine caps at the top 15 by absolute cost) so the PR comment stays readable and the job stays inside a typical 10-minute CI timeout. - Monitoring hooks. Emit metrics for guardrail pass/fail rate per repository, median delta size, and count of unmapped resource types per run; a sustained rise in fail rate after a policy tightening is expected, but a rise with no policy change points at rate-map or baseline drift.
Troubleshooting
Every PR fails the guardrail even for trivial changes. Root cause: the baseline file is missing or was never initialized, so load_baseline returns $0.00 and any positive delta reads as a 100% increase against a zero baseline. Detection: delta_pct in the comment shows exactly 100.0% or inf regardless of plan size. Remediation: seed baseline.json from the workspace’s actual current monthly cost (a one-time manual estimate or export from the pricing API) before enabling the gate, and update it after every successful apply.
Guardrail passes a plan that obviously adds an expensive resource. Root cause: the resource’s type/size combination is absent from RATE_MAP, so it silently prices at $0.00. Detection: check CI logs for no rate mapped for entries matching the resource in question. Remediation: add the (resource_type, size) pair to the rate map, or wire in a live pricing API lookup as a fallback before defaulting to zero.
Delta looks right locally but the posted PR comment is stale. Root cause: the check-run/comment step ran once on PR open and was never re-triggered on subsequent pushes, so reviewers approve against an outdated estimate. Detection: comment timestamp predates the PR’s latest commit. Remediation: trigger the guardrail workflow on pull_request: [opened, synchronize], not just opened, so every push re-prices the plan.
terraform show -json output has no resource_changes key. Root cause: the plan file passed to terraform show was a human-readable plan (terraform plan -out=plan.tfplan followed by terraform show without -json), or the plan file path is wrong. Detection: load_resource_changes returns an empty list and every PR shows a $0.00 delta. Remediation: verify the CI step runs terraform show -json plan.tfplan > plan.json, not terraform show plan.tfplan.
Check-run never appears on the PR, so branch protection doesn’t block anything. Root cause: the GITHUB_TOKEN used to call the Checks API lacks the checks: write permission, which is not granted by default on pull_request events from forked repositories. Detection: post_check_run raises a 403 from the GitHub API. Remediation: run the guardrail on pull_request_target with explicit permissions: checks: write in the workflow, or use a dedicated GitHub App installation token instead of the default GITHUB_TOKEN for fork-originated PRs.
Frequently Asked Questions
How is this different from Infracost?
Infracost is a purpose-built product that parses terraform show -json, prices resources against a maintained, provider-wide pricing catalog, and posts formatted PR comments — the pattern this page describes is exactly Infracost’s core loop, implemented here with a small local rate map instead of a live pricing catalog so the engine is dependency-free and runnable in any CI environment. In production, swap RATE_MAP for a call to Infracost’s API or the cloud providers’ price list APIs.
Should the threshold be an absolute dollar amount or a percentage?
Use both, and fail on either breach. A percentage-only threshold lets a large workspace absorb an unbounded dollar increase as long as it stays under, say, 20% of a already-large baseline. A dollar-only threshold blocks small workspaces from doubling in cost if the absolute delta is still under the cap. GuardrailPolicy in the reference engine enforces both simultaneously.
What happens to resources the guardrail can’t price?
They price at $0.00 and are logged via logger.info("no rate mapped for ...") rather than failing the build — a guardrail that hard-fails on every unmapped resource type becomes a merge blocker for unrelated reasons and gets disabled by frustrated teams. Treat a rising rate of unmapped-resource log lines as a signal to extend the rate map, not as a per-PR failure.
Does the guardrail run on every terraform plan, including destroys?
Yes — price_resource_change prices delete actions as a negative delta, so a plan that tears down infrastructure reduces the projected total and never fails the policy (GuardrailPolicy.evaluate short-circuits to pass whenever delta_usd <= 0). Only net cost increases beyond the threshold block the merge.
How does this relate to tag-policy enforcement in the same pipeline?
They are sibling plan-time gates using the same mechanical pattern — parse terraform show -json, evaluate a policy, fail the build — applied to different dimensions. Enforcing Tag Policies in Terraform blocks resources missing required tags; this guardrail blocks resources whose cost delta exceeds budget. Both typically run as separate jobs in the same workflow so a PR gets one combined governance verdict.
Related
- Cloud Cost Anomaly Detection & Budget Automation — the parent reference positioning this preventive, plan-time stage against the reactive anomaly-detection and budget-alert stages.
- GitHub Actions Cost-Diff Reporting — the deep dive on formatting and posting the PR comment this engine renders.
- Blocking Terraform Plans That Exceed Cost Budgets — the deep dive on wiring the policy verdict into a merge-blocking check-run.
- Enforcing Tag Policies in Terraform — the sibling plan-time governance gate this guardrail typically runs alongside.
- Setting Up FinOps Governance Boundaries in Terraform — the account- and module-level guardrails that constrain what a plan can even propose before it reaches this cost gate.