038 Project 38: Terraform Cost Estimator

Updated

September 8, 2026

038 Build a Terraform Cost Estimator (Simple)

Estimate monthly cost delta from a Terraform plan JSON by mapping resource types to rough unit prices. Not a substitute for Infracost—but excellent for learning plan analysis and CI annotations.

plan.json -> actions × unit price table -> monthly delta $

Problem statement

tf-cost [-prices prices.json] <plan.json>
  • create adds unit monthly cost
  • delete subtracts unit monthly cost
  • replace treat as delete+create (net depends on type change; base: same type → ~0)
  • Unknown types contribute 0 (list them as warnings)

Acceptance criteria

  • Parses plan JSON resource_changes
  • Prints estimated monthly delta
  • Warns on unknown types that change
  • Exit 2 on parse errors; 0 on success
  • Prices overridable via JSON file

Setup

mkdir tfcost && cd tfcost
go mod init example.com/tfcost
# go 1.27

prices.json example:

{
  "aws_instance": 15.0,
  "aws_db_instance": 120.0,
  "aws_lb": 25.0
}

Full main.go

package main

import (
    "encoding/json"
    "flag"
    "fmt"
    "os"
)

type Plan struct {
    ResourceChanges []struct {
        Address string `json:"address"`
        Type    string `json:"type"`
        Change  struct {
            Actions []string `json:"actions"`
        } `json:"change"`
    } `json:"resource_changes"`
}

func loadPrices(path string) (map[string]float64, error) {
    defaults := map[string]float64{
        "aws_instance":    15.0,
        "aws_db_instance": 120.0,
        "aws_lb":          25.0,
    }
    if path == "" {
        return defaults, nil
    }
    b, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    var m map[string]float64
    if err := json.Unmarshal(b, &m); err != nil {
        return nil, err
    }
    for k, v := range defaults {
        if _, ok := m[k]; !ok {
            m[k] = v
        }
    }
    return m, nil
}

func estimate(p Plan, cost map[string]float64) (delta float64, unknown []string) {
    seenUnknown := map[string]bool{}
    for _, rc := range p.ResourceChanges {
        unit, ok := cost[rc.Type]
        if !ok {
            if !seenUnknown[rc.Type] && len(rc.Change.Actions) > 0 && rc.Change.Actions[0] != "no-op" {
                // track types that actually change
                for _, a := range rc.Change.Actions {
                    if a == "create" || a == "delete" {
                        seenUnknown[rc.Type] = true
                        unknown = append(unknown, rc.Type)
                        break
                    }
                }
            }
            continue
        }
        actions := rc.Change.Actions
        if len(actions) == 2 && actions[0] == "delete" && actions[1] == "create" {
            // same type replace → approx net 0 for this toy model
            continue
        }
        for _, a := range actions {
            switch a {
            case "create":
                delta += unit
                fmt.Printf("+ $%.2f  %s (%s)\n", unit, rc.Address, rc.Type)
            case "delete":
                delta -= unit
                fmt.Printf("- $%.2f  %s (%s)\n", unit, rc.Address, rc.Type)
            }
        }
    }
    return delta, unknown
}

func main() {
    pricesPath := flag.String("prices", "", "optional prices JSON")
    flag.Parse()
    if flag.NArg() != 1 {
        fmt.Fprintln(os.Stderr, "usage: tf-cost [-prices prices.json] <plan.json>")
        os.Exit(2)
    }

    cost, err := loadPrices(*pricesPath)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(2)
    }
    b, err := os.ReadFile(flag.Arg(0))
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(2)
    }
    var p Plan
    if err := json.Unmarshal(b, &p); err != nil {
        fmt.Fprintln(os.Stderr, "json:", err)
        os.Exit(2)
    }

    delta, unknown := estimate(p, cost)
    for _, u := range unknown {
        fmt.Fprintf(os.Stderr, "warn: no price for type %s\n", u)
    }
    fmt.Printf("estimated monthly delta: $%.2f\n", delta)
}

Run and verification

go run . plan.json
go run . -prices prices.json plan.json

Tests

package main

import "testing"

func TestEstimateCreate(t *testing.T) {
    var p Plan
    rc := p.ResourceChanges
    _ = rc
    item := struct {
        Address string `json:"address"`
        Type    string `json:"type"`
        Change  struct {
            Actions []string `json:"actions"`
        } `json:"change"`
    }{Address: "aws_instance.web", Type: "aws_instance"}
    item.Change.Actions = []string{"create"}
    p.ResourceChanges = append(p.ResourceChanges, item)
    delta, _ := estimate(p, map[string]float64{"aws_instance": 15})
    if delta != 15 {
        t.Fatalf("%v", delta)
    }
}

Stretch goals

  1. Regional price multipliers.
  2. Count module expansion addresses.
  3. Fail CI if delta > budget (-max-delta 50).
  4. Integrate with risk reporter output.

Pitfalls

Pitfall Fix
Treating prices as accurate label as rough estimate
Ignoring replace define policy (net 0 vs full)
Double-counting classify actions carefully

Learning goals

  • Plan-driven financial signals
  • Extensible price tables
  • Honest limits of toy cost models