DEV Community

devtocash
devtocash

Posted on Originally published at devtocash.com

Build a Savings Plans Coverage Agent: Size AWS Compute Commitments from Cost Explorer Without Overbuying

💡 Originally published on devtocash.com — where this guide stays updated. I write hands-on DevOps/SRE deep-dives there weekly.

The only cost decision you can't undo

Every other FinOps fix on this site is reversible. Retention can be lengthened again, a deleted volume has a snapshot, a rightsized pod can be sized back up. A Savings Plan is different: it is a one- or three-year hourly dollar commitment that AWS bills every hour whether you use it or not, with no cancellation. That asymmetry is why coverage in most accounts sits at 30 to 50 percent for years. Nobody is paid enough to be the person who committed $18 an hour to compute the month before the platform team moved half of it to spot.

This post builds a Savings Plans coverage agent that makes the decision sizeable instead of scary. Deterministic code pulls coverage, utilization, existing plan expiries, and the AWS purchase recommendation from Cost Explorer. It then computes the number AWS won't give you: a defensible hourly floor from real daily data, converted into commitment dollars at the plan rate. The LLM writes the commitment memo, in tranches, with the evidence attached. A human queues the purchase with a cancellation window. The agent's role carries an explicit deny on the purchase call, so the worst thing it can do is be wrong in a document.

It follows the cost anomaly agent and the waste reclamation agent: clean up the bill first, then commit to what's left.

Why the AWS recommendation alone is a trap

Cost Explorer's Savings Plans recommendation is good arithmetic on bad assumptions. It looks back 7, 30, or 60 days of on-demand usage and finds the hourly commitment that maximizes savings if that usage repeats for the full term. Four things it cannot see:

Blind spot What actually happens Where the signal lives
Planned decommissions A service scheduled for sunset next quarter is in the 60-day lookback Backstage lifecycle, a finops:sunset tag, Terraform destroy PRs
Spot migration in flight Karpenter is moving stateless nodes to spot, which Savings Plans never cover NodePool capacity-type requirements, current spot share of EC2 hours
Existing plan expiries Plans bought last year retire mid-term and coverage falls off a cliff savingsplans describe-savings-plans, the end field
Rate conversion Commitment is measured in Savings Plans rates, not on-demand dollars Nowhere obvious, which is why teams overbuy by 30 to 40 percent

The last row is the one that costs real money. A $10 per hour Compute Savings Plan covers roughly $15 per hour of on-demand-priced usage at a typical 35 percent blended discount. Size the commitment to your on-demand floor and you've bought a plan you can only fill by growing. The agent does that conversion explicitly and shows its work.

Step 1: A role that can read the bill and never sign it

Read-only on Cost Explorer and the Savings Plans service, with the purchase actions in an explicit deny so no future policy attachment can add them back.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadCostExplorerAndPlans",
      "Effect": "Allow",
      "Action": [
        "ce:GetSavingsPlansCoverage", "ce:GetSavingsPlansUtilization",
        "ce:GetSavingsPlansUtilizationDetails", "ce:GetSavingsPlansPurchaseRecommendation",
        "ce:GetReservationCoverage", "ce:GetCostAndUsage",
        "savingsplans:DescribeSavingsPlans", "savingsplans:DescribeSavingsPlansOfferings",
        "ec2:DescribeInstances", "organizations:ListAccounts"
      ],
      "Resource": "*"
    },
    {
      "Sid": "NeverPurchaseEvenIfSomeoneAddsIt",
      "Effect": "Deny",
      "Action": [
        "savingsplans:CreateSavingsPlan", "savingsplans:DeleteQueuedSavingsPlan",
        "ec2:PurchaseReservedInstancesOffering", "ce:UpdatePreferences"
      ],
      "Resource": "*"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Two notes. Cost Explorer API calls cost $0.01 each, so the collector below is written to make about a dozen calls per run, not one per day of data. And run this from the payer account with AccountScope=PAYER: Savings Plans float across linked accounts in an organization, and a per-account recommendation will double count the same usage.

Step 2: Evidence, computed not prompted

Four calls give the whole picture. Coverage tells you what's uncovered per day. Utilization tells you whether the plans you already own are full. Existing plans tell you when coverage is about to drop. The recommendation gives you AWS's number to argue with.

import boto3, statistics
from datetime import date, timedelta

ce = boto3.client("ce", region_name="us-east-1")
sp = boto3.client("savingsplans", region_name="us-east-1")
END = date.today()
START = END - timedelta(days=60)
TP = {"Start": START.isoformat(), "End": END.isoformat()}

def daily_coverage():
    """Per-day eligible spend split into covered vs on-demand, in USD."""
    out = []
    token = None
    while True:
        kw = {"TimePeriod": TP, "Granularity": "DAILY",
              "Metrics": ["SpendCoveredBySavingsPlans"]}
        if token:
            kw["NextToken"] = token
        r = ce.get_savings_plans_coverage(**kw)
        for row in r["SavingsPlansCoverages"]:
            c = row["Coverage"]
            out.append({"day": row["TimePeriod"]["Start"],
                        "covered": float(c["SpendCoveredBySavingsPlans"]),
                        "on_demand": float(c["OnDemandCost"]),
                        "pct": float(c["CoveragePercentage"])})
        token = r.get("NextToken")
        if not token:
            return sorted(out, key=lambda x: x["day"])

def utilization():
    t = ce.get_savings_plans_utilization(TimePeriod=TP, Granularity="MONTHLY")["Total"]
    u = t["Utilization"]
    return {"commitment_total": float(u["TotalCommitment"]),
            "unused_total": float(u["UnusedCommitment"]),
            "utilization_pct": float(u["UtilizationPercentage"]),
            "net_savings": float(t["Savings"]["NetSavings"])}

def existing_plans():
    plans = sp.describe_savings_plans(states=["active", "queued"])["savingsPlans"]
    return [{"id": p["savingsPlanId"], "type": p["savingsPlanType"],
             "hourly": float(p["commitment"]), "state": p["state"],
             "end": p["end"][:10], "region": p.get("region", "global")}
            for p in plans]

def aws_recommendation(term="ONE_YEAR", lookback="SIXTY_DAYS"):
    r = ce.get_savings_plans_purchase_recommendation(
        SavingsPlansType="COMPUTE_SP", TermInYears=term,
        PaymentOption="NO_UPFRONT", LookbackPeriodInDays=lookback,
        AccountScope="PAYER")["SavingsPlansPurchaseRecommendation"]
    s = r["SavingsPlansPurchaseRecommendationSummary"]
    d = r["SavingsPlansPurchaseRecommendationDetails"]
    return {"hourly_commitment": float(s["HourlyCommitmentToPurchase"]),
            "est_monthly_savings": float(s["EstimatedMonthlySavingsAmount"]),
            "est_savings_pct": float(s["EstimatedSavingsPercentage"]),
            "offering_id": d[0]["SavingsPlansDetails"]["OfferingId"] if d else None,
            "current_min_hourly_od": float(d[0]["CurrentMinimumHourlyOnDemandSpend"]) if d else None,
            "current_avg_hourly_od": float(d[0]["CurrentAverageHourlyOnDemandSpend"]) if d else None}
Enter fullscreen mode Exit fullscreen mode

CurrentMinimumHourlyOnDemandSpend is the most useful field AWS publishes and almost nobody reads: the lowest hourly on-demand spend in the lookback. It's the ceiling for any commitment you'd call safe, and the recommendation is routinely above it because AWS is optimizing total savings, not risk.

Step 3: The floor, and the rate conversion

Coverage data is daily, so divide by 24 to get an average hourly on-demand figure per day. Take the 10th percentile across 60 days as the floor, not the minimum: one weekend where a batch cluster was off shouldn't set your commitment for a year. Then convert to commitment dollars.

def size_tranche(cov, rec, plans, safety=0.85, horizon_days=90):
    hourly_od = [c["on_demand"] / 24 for c in cov]
    floor_od = statistics.quantiles(hourly_od, n=10)[0]      # P10, in on-demand USD/h
    p50_od = statistics.median(hourly_od)
    discount = rec["est_savings_pct"] / 100                   # AWS's blended estimate for you
    # Commitment is billed at the plan rate: $1 of commitment absorbs 1/(1-d) of on-demand.
    max_safe_commit = floor_od * (1 - discount) * safety

    cutoff = (END + timedelta(days=horizon_days)).isoformat()
    expiring = sum(p["hourly"] for p in plans if p["end"] <= cutoff and p["state"] == "active")

    return {"floor_on_demand_per_h": round(floor_od, 2),
            "median_on_demand_per_h": round(p50_od, 2),
            "assumed_discount": round(discount, 3),
            "max_safe_commitment_per_h": round(max_safe_commit, 2),
            "aws_recommended_per_h": rec["hourly_commitment"],
            "aws_min_hourly_od": rec["current_min_hourly_od"],
            "expiring_within_horizon_per_h": round(expiring, 2),
            "trend_60d_pct": round((statistics.mean(hourly_od[-14:]) /
                                    statistics.mean(hourly_od[:14]) - 1) * 100, 1)}
Enter fullscreen mode Exit fullscreen mode

Run it and compare the two numbers that matter. In a fairly typical account with about $22 per hour of uncovered on-demand at the P10 and a 34 percent estimated discount, the safe commitment comes out near $12.30 per hour, while AWS recommends $17.80. Both are "right". AWS's number saves more if nothing changes. The agent's number survives a 20 percent drop in usage with the plan still fully used. The difference between them is the tranche you buy next month if the usage holds.

Step 4: The signals AWS can't see

Three deterministic checks feed the memo so the model isn't guessing about the future from a spend curve.

Spot share and direction. Savings Plans don't apply to spot. If Karpenter is actively moving capacity to spot, on-demand hours will fall regardless of traffic. Count it:

aws ec2 describe-instances --filters Name=instance-state-name,Values=running \
  --query 'Reservations[].Instances[].[InstanceLifecycle || `on-demand`]' --output text \
  | sort | uniq -c
Enter fullscreen mode Exit fullscreen mode

Then read the NodePools: any pool whose karpenter.sh/capacity-type requirement lists only on-demand is committable usage, and any pool that lists spot is on its way out of the eligible base. The EKS spot post covers which workloads belong in each.

Sunset tags. Adopt one tag, finops:sunset=YYYY-MM, on anything with a decommission date. Sum the hourly on-demand of tagged instances (GetCostAndUsage grouped by that tag key) and subtract it from the floor. If your catalog tracks lifecycle, the same query runs against Backstage instead.

Expiry cliff. The expiring_within_horizon_per_h figure above. Coverage that looks healthy at 78 percent today with $9 per hour retiring in November is a 45 percent coverage account in December. Renewals belong in the memo as a separate line from new commitment, because they carry no growth risk.

Step 5: The memo is the LLM's whole job

The model gets one tool result, the evidence bundle, and must produce a structured memo. It never gets a purchase tool. Output schema first, prompt second:

{
  "type": "object",
  "required": ["decision", "tranches", "risks", "confidence", "revisit_after"],
  "properties": {
    "decision": {"enum": ["commit", "renew_only", "wait", "reduce_exposure"]},
    "tranches": {"type": "array", "items": {"type": "object",
      "required": ["hourly_commitment", "plan_type", "term", "when", "justification"],
      "properties": {
        "hourly_commitment": {"type": "number"},
        "plan_type": {"enum": ["COMPUTE_SP", "EC2_INSTANCE_SP"]},
        "term": {"enum": ["ONE_YEAR"]},
        "when": {"type": "string", "description": "ISO month to queue the purchase"},
        "justification": {"type": "string"}}}},
    "risks": {"type": "array", "items": {"type": "string"}},
    "confidence": {"enum": ["high", "medium", "low"]},
    "revisit_after": {"type": "string"}
  }
}
Enter fullscreen mode Exit fullscreen mode

term is deliberately locked to one year in the schema. Three-year plans are a leadership decision with a finance signature, and a model with a 60-day window has no business proposing one. The system prompt sets the rules the arithmetic can't:

You are writing a Savings Plans commitment memo for a FinOps reviewer.
Rules:
- Never propose total new commitment above max_safe_commitment_per_h.
- Subtract sunset_hourly and any spot-migration estimate before sizing.
- Renewals of expiring plans are a separate tranche from new commitment.
- If trend_60d_pct is below -10, decision must be "wait" or "renew_only".
- If utilization_pct of existing plans is below 95, decision must not be "commit".
- Split new commitment into at least two monthly tranches so expiries stagger.
- Every tranche cites the evidence field it was derived from.
- Say explicitly what would make this memo wrong.
Enter fullscreen mode Exit fullscreen mode

The utilization rule matters more than it looks. If you're already leaving commitment unused, buying more is the one thing guaranteed to make it worse, and the memo should say so before anyone opens the console.

Step 6: A human queues it, with a cancellation window

The purchase is a queued Savings Plan, which is the closest thing AWS offers to an undo button. Queued plans start at a future time you choose and can be deleted any time before that. The reviewer, not the agent, runs:

# Pick the offering AWS recommended (or list them)
aws savingsplans describe-savings-plans-offerings \
  --plan-types Compute --durations 31536000 --payment-options "No Upfront" \
  --query 'searchResults[0].offeringId' --output text

# Queue tranche 1 to start on the 1st; delete it before then if the memo turns out wrong
aws savingsplans create-savings-plan \
  --savings-plan-offering-id <offering-id> \
  --commitment 6.15 \
  --purchase-time 2026-10-01T00:00:00Z \
  --client-token sp-memo-2026-09-25-t1 \
  --tags finops:memo=2026-09-25,finops:tranche=1

aws savingsplans describe-savings-plans --states queued
aws savingsplans delete-queued-savings-plan --savings-plan-id <id>   # the undo
Enter fullscreen mode Exit fullscreen mode

Wire the memo into the same approval gate you use for agent write actions, but with one difference: the approver is FinOps or engineering leadership, not the on-call engineer. The client token and tags make the purchase attributable to a specific memo, which is the audit trail you'll want when someone asks in eight months why the plan was sized that way.

What this agent will not do, and why

It will not purchase. The IAM deny is the mechanism, and the memo format is the contract. Even a fully automated tranche pipeline should terminate at create-savings-plan run by a human, because the failure mode is a year long.

It will not recommend EC2 Instance Savings Plans by default. They pay up to 72 percent versus 66 percent for Compute, but lock you to an instance family in a region. Any account with a Graviton migration, a Fargate move, or a Karpenter consolidation pass in its future loses more on stranded commitment than it gains in discount. Propose them only for a family that has been stable for a year and is tagged as such.

It will not trust a seven-day lookback. Sixty days catches at least two month-end cycles. If your business is seasonal, feed the same collector last year's peak and trough months and let the floor be the trough.

It will not fix a low coverage number by itself. Coverage is a ratio, and the denominator is your on-demand spend. Cutting the idle workloads first raises coverage without spending a dollar, and it also shrinks the floor the agent would otherwise commit you to. Run the reclamation agents in the same week, and run this one last.

Running it

Monthly, on the 20th, so the memo lands before month-end and the purchase queues for the 1st. One run is about twelve Cost Explorer calls and a single model call over a bundle of a few kilobytes, which puts the whole thing under a dollar per month. The output is a memo in a pull request against a finops/commitments/ directory, with the evidence JSON committed alongside it. Twelve months later that directory is the history nobody else has: what the floor was, what was bought, what the utilization turned out to be, and whether the model's stated risks were the ones that showed up.


📌 Read the latest version of this guide — plus the full library of DevOps, SRE, Kubernetes, observability & cloud-cost guides — on devtocash.com.

Top comments (0)