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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- Resources inside modules are shown by their Terraform address,
`module.tier["web"].aws_instance.this[0]`, in every output format and
PR comment. They read `aws_instance.module.tier["web"].this[0]`, a form
Terraform does not accept. Resources outside modules look the same as
before. In `--format json`, the `resource` field changes accordingly;
`kind` and `name` do not.

### Fixed

- Usage-file keys written as Terraform addresses
(`module.frontend.aws_instance.web`), as the usage docs describe, now
match; they were reported as unmatched and their usage ignored. Keys in
the old form still match, with a warning suggesting the new key; if a
file has both, the Terraform address wins.
- `--what-if` accepts Terraform addresses
(`module.web.aws_instance.this[0].instance_type=m7i.large`), including
for_each keys that contain dots. The old form still works, with a
warning.

Saved baselines are unaffected: diffs match resources on kind and name,
so a baseline written by an earlier version still diffs cleanly.

## [0.3.18] - 2026-09-29

### Fixed
Expand Down
33 changes: 28 additions & 5 deletions cmd/c3x/estimate.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io"
"os"
"path/filepath"
"sort"
"strings"

"github.com/c3xdev/c3x/internal/calculator"
Expand Down Expand Up @@ -154,7 +155,7 @@ precedence matches Terraform's: defaults < auto.tfvars < --var-file <
cmd.Flags().StringVar(&usagePath, "usage", "",
"path to a c3x-usage.yml file with runtime usage quantities (monthly_requests, monthly_storage_gb, etc.; also usage_path in .c3x.toml)")
cmd.Flags().StringArrayVar(&whatIfs, "what-if", nil,
"override an attribute: `kind.name.attr=value` (repeatable; bool/int/float/string coercion)")
"override an attribute: `<resource address>.<attr>=value`, e.g. `module.web.aws_instance.this[0].instance_type=m7i.large` (repeatable; bool/int/float/string coercion)")
cmd.Flags().StringVar(&saveBaseline, "save-baseline", "",
"after the estimate, write the JSON representation to this path for use as a `c3x diff` baseline")
cmd.Flags().BoolVar(&strict, "strict", false, strictHelp)
Expand Down Expand Up @@ -337,11 +338,18 @@ func applyUsageAndWhatIf(cmd *cobra.Command, resources []domain.Resource, usageP
if err != nil {
return fmt.Errorf("--what-if: %w", err)
}
for _, o := range ovs {
if o.Legacy {
fmt.Fprintf(cmd.ErrOrStderr(),
"c3x: warning: --what-if %s.%s.%s uses the old address form; write %s.%s\n",
o.Kind, o.Name, o.Attr, o.Address(), o.Attr)
}
}
if unmatched := whatif.Apply(resources, ovs); len(unmatched) > 0 {
for _, o := range unmatched {
fmt.Fprintf(cmd.ErrOrStderr(),
"c3x: warning: --what-if %s.%s.%s=... did not match any resource\n",
o.Kind, o.Name, o.Attr)
"c3x: warning: --what-if %s.%s=... did not match any resource\n",
o.Address(), o.Attr)
}
}
}
Expand All @@ -360,14 +368,29 @@ func applyUsage(stderr io.Writer, resources []domain.Resource, usagePath string)
if err != nil {
return fmt.Errorf("usage file: %w", err)
}
if unmatched := usage.Apply(resources, f); len(unmatched) > 0 {
rep := usage.ApplyWithReport(resources, f)
if len(rep.Unmatched) > 0 {
fmt.Fprintf(stderr,
"c3x: warning: %d usage entries did not match any resource (%v)\n",
len(unmatched), unmatched)
len(rep.Unmatched), rep.Unmatched)
}
for _, old := range sortedKeys(rep.Legacy) {
fmt.Fprintf(stderr,
"c3x: warning: usage key %q uses the old address form; rename it to %q\n",
old, rep.Legacy[old])
}
return nil
}

func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

// writeRendered routes the estimate through the appropriate renderer
// and writes to the command's stdout. Returning the render error lets
// the CLI surface a clean diagnostic if marshalling fails.
Expand Down
2 changes: 1 addition & 1 deletion cmd/c3x/recommend.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ non-prod, GCP pd-standard → pd-balanced.`,
cmd.Flags().StringArrayVar(&varFiles, "var-file", nil, "tfvars files (repeatable)")
cmd.Flags().StringArrayVar(&vars, "var", nil, "variable override name=value (repeatable)")
cmd.Flags().StringVar(&usagePath, "usage", "", "path to a c3x-usage.yml file")
cmd.Flags().StringArrayVar(&whatIfs, "what-if", nil, "attribute override kind.name.attr=value (repeatable)")
cmd.Flags().StringArrayVar(&whatIfs, "what-if", nil, "attribute override <resource address>.<attr>=value (repeatable)")
cmd.Flags().BoolVar(&offline, "offline", false, "use the offline pricing stub")
cmd.Flags().BoolVar(&noCache, "no-cache", false, "bypass the on-disk price cache")
cmd.Flags().StringVar(&cachePath, "cache-path", "", "override the cache file path")
Expand Down
18 changes: 13 additions & 5 deletions internal/domain/domain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,13 +227,21 @@ func TestTerraformAddress(t *testing.T) {

// TestTerraformAddressDiffersFromLabelForModules pins the distinction so
// nobody "simplifies" TerraformAddress back into Label.
func TestTerraformAddressDiffersFromLabelForModules(t *testing.T) {
func TestLabelIsTheTerraformAddress(t *testing.T) {
t.Parallel()
ref := domain.Reference{Kind: "aws_instance", Name: "module.frontend.web"}
if ref.Label() == ref.TerraformAddress() {
t.Fatal("module Label() must not equal TerraformAddress()")
if got := ref.Label(); got != "module.frontend.aws_instance.web" {
t.Errorf("Label() = %q, want the Terraform address", got)
}
if ref.Label() != "aws_instance.module.frontend.web" {
t.Errorf("Label() changed unexpectedly: %q", ref.Label())
if ref.Label() != ref.TerraformAddress() {
t.Error("Label() must equal TerraformAddress()")
}
// The pre-0.3.19 form is still available for matching old keys.
if got := ref.LegacyLabel(); got != "aws_instance.module.frontend.web" {
t.Errorf("LegacyLabel() = %q", got)
}
plain := domain.Reference{Kind: "aws_instance", Name: "web[0]"}
if plain.Label() != plain.LegacyLabel() {
t.Error("outside a module both forms must be identical")
}
}
21 changes: 15 additions & 6 deletions internal/domain/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,27 @@ type Reference struct {
}

// Label renders the reference for display: "kind.name".
func (r Reference) Label() string { return r.Kind + "." + r.Name }
// Label is how a resource is shown and referred to everywhere: its
// Terraform address (module.web.aws_instance.this[0]); see
// [Reference.TerraformAddress]. Resources outside a module read the same
// in every form (aws_instance.web).
func (r Reference) Label() string { return r.TerraformAddress() }

// LegacyLabel is the form Label had before 0.3.19, which put the kind in
// front of the module path (aws_instance.module.web.this[0]). Usage files
// and --what-if still accept it so existing keys keep matching.
func (r Reference) LegacyLabel() string { return r.Kind + "." + r.Name }

func (r Reference) String() string { return r.Label() }

// TerraformAddress renders the reference as a canonical Terraform
// resource address, the form `-target` expects.
//
// This is NOT Label(). The parsers store the module path inside Name with
// the kind stripped out (Kind="aws_instance", Name="module.frontend.web"
// for `module.frontend.aws_instance.web`), so the kind has to be spliced
// back in ahead of the final segment rather than prepended to the whole
// string: Label() would yield `aws_instance.module.frontend.web`, which
// The parsers store the module path inside Name with the kind stripped
// out (Kind="aws_instance", Name="module.frontend.web" for
// `module.frontend.aws_instance.web`), so the kind has to be spliced back
// in ahead of the final segment rather than prepended to the whole
// string, which would yield `aws_instance.module.frontend.web`, a form
// Terraform does not accept.
//
// The final segment is located by scanning for the last '.' outside any
Expand Down
48 changes: 48 additions & 0 deletions internal/render/baseline_compat_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package render_test

import (
"testing"

"github.com/c3xdev/c3x/internal/domain"
"github.com/c3xdev/c3x/internal/render"
"github.com/shopspring/decimal"
)

// A baseline saved by c3x before 0.3.19 carries the old label in its
// "resource" field. Diffs match on kind and name, so it must still line
// up with a new estimate: no spurious added/removed pairs.
func TestBaselineFromBeforeTerraformAddressesStillDiffs(t *testing.T) {
old := []byte(`{
"costs": [{
"resource": "aws_instance.module.tier[\"web\"].this[0]",
"kind": "aws_instance",
"name": "module.tier[\"web\"].this[0]",
"line_items": [],
"monthly_subtotal": "75.98",
"currency": "USD"
}],
"project_total": "75.98",
"currency": "USD",
"generated_at": "2026-09-28T00:00:00Z"
}`)
baseline, err := render.DecodeEstimate(old)
if err != nil {
t.Fatal(err)
}
current := domain.Estimate{
Currency: domain.CurrencyUSD,
ProjectTotal: decimal.RequireFromString("149.57"),
Costs: []domain.Cost{{
Resource: domain.Reference{Kind: "aws_instance", Name: `module.tier["web"].this[0]`},
MonthlySubtotal: decimal.RequireFromString("149.57"),
Currency: domain.CurrencyUSD,
}},
}
d := domain.ComputeDiff(baseline, current)
if len(d.Resources) != 1 || d.Resources[0].Kind != domain.DeltaModified {
t.Fatalf("diff = %+v, want one modified resource", d.Resources)
}
if got := d.Resources[0].Resource.Label(); got != `module.tier["web"].aws_instance.this[0]` {
t.Errorf("label = %q, want the Terraform address", got)
}
}
2 changes: 1 addition & 1 deletion internal/render/csv.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func RenderCSV(est domain.Estimate) (string, error) {
}
currency := est.Currency.String()
for _, c := range est.Costs {
ref := c.Resource.Kind + "." + c.Resource.Name
ref := c.Resource.Label()
if len(c.LineItems) == 0 {
// Resource with no line items — emit one row so it isn't
// invisible in the spreadsheet. Useful for FREE kinds.
Expand Down
4 changes: 2 additions & 2 deletions internal/render/diff_formats.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func RenderCSVDiff(d domain.Diff) (string, error) {
currency := d.Currency.String()
for _, r := range d.Resources {
_ = w.Write([]string{
r.Resource.Kind + "." + r.Resource.Name,
r.Resource.Label(),
r.Resource.Kind,
deltaKindLabel(r.Kind),
r.Baseline.Round(2).String(),
Expand Down Expand Up @@ -180,7 +180,7 @@ func RenderHTMLDiff(d domain.Diff) (string, error) {
}
for _, r := range d.Resources {
data.Rows = append(data.Rows, htmlDiffRow{
Label: r.Resource.Kind + "." + r.Resource.Name,
Label: r.Resource.Label(),
Change: deltaKindLabel(r.Kind),
Baseline: r.Baseline.Round(2).String(),
Current: r.Current.Round(2).String(),
Expand Down
60 changes: 60 additions & 0 deletions internal/usage/address_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package usage_test

import (
"testing"

"github.com/c3xdev/c3x/internal/domain"
"github.com/c3xdev/c3x/internal/usage"
)

func moduleResource() []domain.Resource {
return []domain.Resource{{Ref: domain.Reference{Kind: "aws_instance", Name: `module.tier["web"].this[0]`}}}
}

func TestUsageKeyTerraformAddress(t *testing.T) {
rs := moduleResource()
rep := usage.ApplyWithReport(rs, usage.File{ResourceUsage: map[string]map[string]any{
`module.tier["web"].aws_instance.this[0]`: {"monthly_hours": 100},
}})
if len(rep.Unmatched) != 0 || len(rep.Legacy) != 0 {
t.Fatalf("report = %+v, want a clean match", rep)
}
if rs[0].Attributes["monthly_hours"] != 100 {
t.Errorf("usage not applied: %v", rs[0].Attributes)
}
}

// Keys written in the pre-0.3.19 form still match, and are reported so
// the CLI can suggest the rename.
func TestUsageKeyLegacyFormStillMatches(t *testing.T) {
rs := moduleResource()
rep := usage.ApplyWithReport(rs, usage.File{ResourceUsage: map[string]map[string]any{
`aws_instance.module.tier["web"].this[0]`: {"monthly_hours": 50},
}})
if len(rep.Unmatched) != 0 {
t.Fatalf("legacy key unmatched: %v", rep.Unmatched)
}
if rs[0].Attributes["monthly_hours"] != 50 {
t.Errorf("legacy key not applied: %v", rs[0].Attributes)
}
want := `module.tier["web"].aws_instance.this[0]`
if got := rep.Legacy[`aws_instance.module.tier["web"].this[0]`]; got != want {
t.Errorf("Legacy suggestion = %q, want %q", got, want)
}
}

// With both forms in one file, the Terraform address wins and the legacy
// key is reported as unmatched rather than silently merged.
func TestUsageKeyTerraformAddressWinsOverLegacy(t *testing.T) {
rs := moduleResource()
rep := usage.ApplyWithReport(rs, usage.File{ResourceUsage: map[string]map[string]any{
`module.tier["web"].aws_instance.this[0]`: {"monthly_hours": 100},
`aws_instance.module.tier["web"].this[0]`: {"monthly_hours": 50},
}})
if rs[0].Attributes["monthly_hours"] != 100 {
t.Errorf("monthly_hours = %v, want the Terraform-address value", rs[0].Attributes["monthly_hours"])
}
if len(rep.Unmatched) != 1 || rep.Unmatched[0] != `aws_instance.module.tier["web"].this[0]` {
t.Errorf("unmatched = %v, want the shadowed legacy key", rep.Unmatched)
}
}
49 changes: 39 additions & 10 deletions internal/usage/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@
// standard_storage_gb: 500
// monthly_tier_1_requests: 1000
//
// Resource keys are `kind.name`; nested-module addresses use the same
// dot notation the parser emits (`module.frontend.aws_instance.web`).
// Resource keys are Terraform addresses: `aws_instance.web`,
// `aws_instance.web[0]`, `module.frontend.aws_instance.web`. The form
// c3x printed before 0.3.19, with the kind in front of the module path
// (`aws_instance.module.frontend.web`), is still accepted.
package usage

import (
"fmt"
"os"
"sort"

"github.com/c3xdev/c3x/internal/domain"
"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -80,8 +83,26 @@ func Load(path string) (File, error) {
// runtime, which is more authoritative than the static default in
// the Terraform source.
func Apply(resources []domain.Resource, f File) (unmatched []string) {
return ApplyWithReport(resources, f).Unmatched
}

// Report is what [ApplyWithReport] found beyond applying the file.
type Report struct {
// Unmatched lists keys that matched no resource.
Unmatched []string
// Legacy maps each key written in the pre-0.3.19 form
// (aws_instance.module.x.web) to its Terraform address
// (module.x.aws_instance.web), so the caller can suggest the rename.
Legacy map[string]string
}

// ApplyWithReport is [Apply] that also reports keys written in the
// legacy form. A key in the Terraform-address form wins when a file has
// both for the same resource.
func ApplyWithReport(resources []domain.Resource, f File) Report {
var rep Report
if len(f.ResourceUsage) == 0 && len(f.Defaults) == 0 {
return nil
return rep
}
matched := make(map[string]struct{}, len(f.ResourceUsage))

Expand All @@ -90,20 +111,28 @@ func Apply(resources []domain.Resource, f File) (unmatched []string) {
if d, ok := f.Defaults[r.Ref.Kind]; ok {
applyAttrs(&resources[i], d)
}
// Match by label (kind.name). The exact address the parser
// emits — including module prefixes and count/for_each keys —
// is what users put in the YAML.
if u, ok := f.ResourceUsage[r.Ref.Label()]; ok {
// Match by the resource's Terraform address, including module
// prefixes and count/for_each keys, then by the legacy form.
addr, legacy := r.Ref.Label(), r.Ref.LegacyLabel()
if u, ok := f.ResourceUsage[addr]; ok {
applyAttrs(&resources[i], u)
matched[addr] = struct{}{}
} else if u, ok := f.ResourceUsage[legacy]; ok && legacy != addr {
applyAttrs(&resources[i], u)
matched[r.Ref.Label()] = struct{}{}
matched[legacy] = struct{}{}
if rep.Legacy == nil {
rep.Legacy = map[string]string{}
}
rep.Legacy[legacy] = addr
}
}
for key := range f.ResourceUsage {
if _, ok := matched[key]; !ok {
unmatched = append(unmatched, key)
rep.Unmatched = append(rep.Unmatched, key)
}
}
return unmatched
sort.Strings(rep.Unmatched)
return rep
}

// applyAttrs writes each key/value into the resource's Attributes map.
Expand Down
Loading
Loading