Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- `assumed_count` caveat. When a resource's `count` or `for_each`, or
that of the module it sits in, is computed from a data source c3x
gives a placeholder (`data.aws_availability_zones` is three zones in
the provider's region), each priced instance now carries a caveat
naming the value assumed: "instance count assumes
data.aws_availability_zones.available.names = [us-east-1a, us-east-1b,
us-east-1c]; price a plan JSON for exact counts". It shows in every
output format and the PR comment, counts toward `caveat_count`, and
fails `--strict` (exit 3). Free resources don't get it. The stderr
warning that used to say this is now a debug line; a `dynamic` block
whose for_each rests on a placeholder still warns.

## [0.3.16] - 2026-09-29

### Fixed
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ exit non-zero, so a pull request that blows the budget fails the build.
A directory is evaluated statically, with no cloud credentials. That is
convenient, but values Terraform only knows at plan time are estimated:
data sources, module outputs computed from them, and some counts. Those
lines are marked `unresolved_attribute`. The plan has the real values:
lines are marked `unresolved_attribute`, and a resource whose count or
for_each rests on a data source (one NAT gateway per availability zone)
is marked `assumed_count`. The plan has the real values:

```yaml
- run: terraform plan -out=tfplan && terraform show -json tfplan > plan.json
Expand Down Expand Up @@ -195,6 +197,7 @@ comment:
| `no_price` | The lookup matched nothing, so a non-free resource shows $0 |
| `usage_not_provided` | A usage-driven cost (requests, GB processed, LCUs) is $0 because no usage was given; supply it with `--usage` |
| `unresolved_attribute` | An attribute the price depends on could not be evaluated statically, so a default was used; a plan JSON input avoids this |
| `assumed_count` | The resource's count or for_each (or its module's) is computed from a data source c3x cannot query, such as `data.aws_availability_zones`, so the number of instances rests on a placeholder (three zones in the provider's region); the caveat names the value assumed, and a plan JSON input gives the exact count |
| `stale_price` | The pricing API was unreachable, so a cached price past its freshness window was used |

`--strict` makes any of these fail the run with exit code 3, distinct
Expand Down
123 changes: 123 additions & 0 deletions cmd/c3x/assumed_count_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package main

import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)

// writeAZProject writes a configuration with one instance per placeholder
// availability zone, one with a literal count, and a free resource whose
// count also rests on the zones.
func writeAZProject(t *testing.T) string {
t.Helper()
dir := t.TempDir()
tf := `
provider "aws" { region = "us-east-1" }
data "aws_availability_zones" "available" {}
resource "aws_instance" "per_az" {
count = length(data.aws_availability_zones.available.names)
instance_type = "t3.micro"
}
resource "aws_instance" "fixed" {
count = 2
instance_type = "t3.micro"
}
resource "aws_vpc" "per_az" {
count = length(data.aws_availability_zones.available.names)
cidr_block = "10.0.0.0/16"
}
`
if err := os.WriteFile(filepath.Join(dir, "main.tf"), []byte(tf), 0o644); err != nil {
t.Fatal(err)
}
return dir
}

// An instance count computed from a data source placeholder is an
// assumed_count caveat on each priced instance, not on a free resource
// nor on a literal count.
func TestAssumedCountCaveatInJSON(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
out, err := runCLI(t, "estimate", "--path", writeAZProject(t), "--format", "json",
"--pricing-endpoint", flatPricing(t), "--no-cache")
if err != nil {
t.Fatalf("estimate: %v\n%s", err, out)
}
start := strings.Index(out, "{")
if start < 0 {
t.Fatalf("no JSON in output:\n%s", out)
}
var got struct {
CaveatCount int `json:"caveat_count"`
Costs []struct {
Resource string `json:"resource"`
Subtotal string `json:"monthly_subtotal"`
Caveats []struct {
Code string `json:"code"`
Detail string `json:"detail"`
} `json:"caveats"`
} `json:"costs"`
}
if err := json.NewDecoder(strings.NewReader(out[start:])).Decode(&got); err != nil {
t.Fatalf("decode: %v\n%s", err, out)
}
assumed := map[string]string{}
seen := map[string]bool{}
for _, c := range got.Costs {
seen[c.Resource] = true
for _, cv := range c.Caveats {
if cv.Code == "assumed_count" {
assumed[c.Resource] = cv.Detail
}
}
}
want := "instance count assumes data.aws_availability_zones.available.names = [us-east-1a, us-east-1b, us-east-1c]; price a plan JSON for exact counts"
for _, r := range []string{"aws_instance.per_az[0]", "aws_instance.per_az[1]", "aws_instance.per_az[2]"} {
if assumed[r] != want {
t.Errorf("%s: assumed_count detail = %q, want %q", r, assumed[r], want)
}
}
for _, r := range []string{"aws_instance.fixed[0]", "aws_instance.fixed[1]", "aws_vpc.per_az[0]"} {
if d, ok := assumed[r]; ok {
t.Errorf("%s has an assumed_count caveat (%q); want none", r, d)
}
}
if !seen["aws_instance.fixed[0]"] {
t.Errorf("aws_instance.fixed[0] missing from costs: %v", seen)
}
if got.CaveatCount < 3 {
t.Errorf("caveat_count = %d; the three assumed counts must count", got.CaveatCount)
}
}

// The caveat shows in the text breakdown and the markdown PR comment,
// and --strict fails on it with exit code 3.
func TestAssumedCountCaveatRendersAndFailsStrict(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := writeAZProject(t)
endpoint := flatPricing(t)
for _, format := range []string{"text", "markdown"} {
out, err := runCLI(t, "estimate", "--path", dir, "--format", format,
"--pricing-endpoint", endpoint, "--no-cache")
if err != nil {
t.Fatalf("%s: %v\n%s", format, err, out)
}
want := "instance count assumes data.aws_availability_zones.available.names"
if n := strings.Count(out, want); n != 3 {
t.Errorf("%s output mentions the assumed count %d times, want 3 (the priced instances only):\n%s", format, n, out)
}
}

out, err := runCLI(t, "estimate", "--path", dir, "--strict",
"--pricing-endpoint", endpoint, "--no-cache")
if !errors.Is(err, errStrictCaveats) {
t.Fatalf("--strict err = %v, want errStrictCaveats\n%s", err, out)
}
if !strings.Contains(out, "assumed_count") {
t.Errorf("--strict summary lacks assumed_count:\n%s", out)
}
}
2 changes: 1 addition & 1 deletion cmd/c3x/strict.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ var errStrictCaveats = errors.New("strict: estimate has caveats")

const strictHelp = "fail with exit code 3 when any part of the estimate rests on an assumption: a price quoted " +
"from another region, a lookup that matched nothing, usage not provided, an attribute that could not be evaluated, " +
"or a stale cached price. The caveats are always shown; this makes them fail the run"
"an instance count assumed from a data source, or a stale cached price. The caveats are always shown; this makes them fail the run"

// enforceStrict prints a one-line summary per caveat kind and returns
// errStrictCaveats when strict is set and there is anything to report.
Expand Down
13 changes: 13 additions & 0 deletions internal/calculator/caveats.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,19 @@ func unresolvedCaveats(r domain.Resource, reads map[string]bool) []domain.Caveat
return out
}

// assumedCountCaveat reports that the resource's instance count rests on
// a data source placeholder. Only a resource that costs something gets
// it: a free resource's count cannot move the total.
func assumedCountCaveat(r domain.Resource, subtotal decimal.Decimal) []domain.Caveat {
if r.AssumedCount == "" || subtotal.IsZero() {
return nil
}
return []domain.Caveat{{
Code: domain.CaveatAssumedCount,
Detail: fmt.Sprintf("instance count assumes %s; price a plan JSON for exact counts", r.AssumedCount),
}}
}

func orDefault(s, fallback string) string {
if s == "" {
return fallback
Expand Down
39 changes: 39 additions & 0 deletions internal/calculator/caveats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,42 @@ func TestUnresolvedAttributeReportedOnlyWhenPriceDependsOnIt(t *testing.T) {
t.Errorf("detail = %q", c.ResourceCaveats[0].Detail)
}
}

// An instance count assumed from a data source placeholder is a resource
// caveat naming the assumption, but only on a resource that costs
// something, and never on one whose count assumed nothing.
func TestAssumedCountCaveat(t *testing.T) {
t.Parallel()
const assumed = "data.aws_availability_zones.available.names = [us-east-1a, us-east-1b, us-east-1c]"
priced := scripted{decimal.RequireFromString("0.045"), domain.PriceSourceLive}

c := estimateWith(t, priced, domain.Resource{
Ref: domain.Reference{Kind: "aws_nat_gateway", Name: "nat[0]"},
Attributes: map[string]any{},
AssumedCount: assumed,
})
if n := codes(c)[domain.CaveatAssumedCount]; n != 1 {
t.Fatalf("want 1 assumed_count caveat, got %v", c.Caveats())
}
want := "instance count assumes " + assumed + "; price a plan JSON for exact counts"
if c.ResourceCaveats[0].Detail != want {
t.Errorf("detail = %q, want %q", c.ResourceCaveats[0].Detail, want)
}

free := estimateWith(t, scripted{decimal.Zero, domain.PriceSourceLive}, domain.Resource{
Ref: domain.Reference{Kind: "aws_vpc", Name: "main[0]"},
Attributes: map[string]any{"cidr_block": "10.0.0.0/16"},
AssumedCount: assumed,
})
if n := codes(free)[domain.CaveatAssumedCount]; n != 0 {
t.Errorf("a free resource got an assumed_count caveat: %v", free.Caveats())
}

literal := estimateWith(t, priced, domain.Resource{
Ref: domain.Reference{Kind: "aws_nat_gateway", Name: "nat[0]"},
Attributes: map[string]any{},
})
if n := codes(literal)[domain.CaveatAssumedCount]; n != 0 {
t.Errorf("a resource with no assumed count got the caveat: %v", literal.Caveats())
}
}
2 changes: 1 addition & 1 deletion internal/calculator/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ func (e *Engine) costFor(ctx context.Context, r domain.Resource) (domain.Cost, s
MonthlySubtotal: subtotal.Round(2),
Currency: e.currency,
Action: r.Action,
ResourceCaveats: unresolvedCaveats(r, e.reads.of(def)),
ResourceCaveats: append(assumedCountCaveat(r, subtotal), unresolvedCaveats(r, e.reads.of(def))...),
}, skipReason, nil
}

Expand Down
4 changes: 4 additions & 0 deletions internal/domain/caveat.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ const (
// CaveatUnresolved: an attribute the price depends on could not be
// evaluated statically, so a catalog default was used for it.
CaveatUnresolved = "unresolved_attribute"
// CaveatAssumedCount: how many instances of the resource exist was
// computed from a data source placeholder (the availability zones of
// a region, say), so the instance count is an assumption.
CaveatAssumedCount = "assumed_count"
// CaveatStub: priced with the offline stub, not a real price.
CaveatStub = "offline_stub"
// CaveatStalePrice: the pricing API was unreachable, so a cached
Expand Down
8 changes: 8 additions & 0 deletions internal/domain/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ type Resource struct {
// reports it rather than pricing silently. Only the HCL parser fills
// this: in plan JSON every value is already resolved.
Unresolved []string
// AssumedCount is set when this instance exists because a count or
// for_each (its own, or an enclosing module's) was computed from a
// data source c3x cannot query and gave a placeholder value. It
// describes the assumption, e.g. "data.aws_availability_zones.
// available.names = [us-east-1a, us-east-1b, us-east-1c]"; the
// calculator reports it as an assumed_count caveat. Only the HCL
// parser fills this: a plan JSON has the real instances.
AssumedCount string
}

// PlanAction describes what Terraform intends to do with a resource.
Expand Down
47 changes: 37 additions & 10 deletions internal/parser/terraform/data.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package terraform

import (
"context"
"fmt"
"log/slog"
"sort"
Expand All @@ -24,7 +25,8 @@ import (
// current region, account and project), a placeholder with a
// plausible value. Placeholders are marked (see placeholderMark), so
// every value computed from one is traceable: a resource whose count
// or for_each depends on a placeholder is logged as a warning, and an
// or for_each depends on a placeholder carries an assumed_count
// caveat (see domain.Resource.AssumedCount), and an
// attribute computed from one is reported unresolved, as it was before
// placeholders existed.
//
Expand Down Expand Up @@ -212,17 +214,42 @@ func withPlaceholders(v cty.Value, deps []placeholderMark) cty.Value {
return v
}

// warnPlaceholders reports that how many instances of address exist
// (what: "count", "for_each") rests on placeholder data source values.
func warnPlaceholders(logger *slog.Logger, what, address string, deps []placeholderMark) {
assumed := make([]string, len(deps))
// describePlaceholders renders the placeholders a value was computed
// from, for the user: "data.aws_availability_zones.available.names =
// [us-east-1a, us-east-1b, us-east-1c]". Empty when there are none.
func describePlaceholders(deps []placeholderMark) string {
parts := make([]string, len(deps))
for i, d := range deps {
assumed[i] = d.Ref + " = " + d.Assumed
parts[i] = d.Ref + " = " + d.Assumed
}
logger.Warn(what+" depends on a data source c3x cannot query; it assumed a placeholder value, "+
"so the number of instances priced may differ from what Terraform would create "+
"(price a plan JSON for exact results)",
"resource", address, "assumed", strings.Join(assumed, "; "))
return strings.Join(parts, "; ")
}

// joinAssumed combines the assumptions of an enclosing module instance
// and of the resource's own count / for_each, dropping duplicates.
func joinAssumed(outer, inner string) string {
switch {
case outer == "", outer == inner:
return inner
case inner == "":
return outer
}
return outer + "; " + inner
}

// logPlaceholders reports that how many instances of address exist
// (what: "count", "for_each") rests on placeholder data source values,
// described by assumed (see describePlaceholders). For a resource or
// module count / for_each it is a debug line: the assumption reaches the
// output as an assumed_count caveat on every priced instance, which says
// the same thing where the number is. A dynamic block has no caveat of
// its own, so it stays a warning.
func logPlaceholders(logger *slog.Logger, level slog.Level, what, address, assumed string) {
logger.Log(context.Background(), level,
what+" depends on a data source c3x cannot query; it assumed a placeholder value, "+
"so the number of instances priced may differ from what Terraform would create "+
"(price a plan JSON for exact results)",
"resource", address, "assumed", assumed)
}

func orDefault(s, fallback string) string {
Expand Down
Loading
Loading