diff --git a/CHANGELOG.md b/CHANGELOG.md index e7221c3..330d545 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cmd/c3x/estimate.go b/cmd/c3x/estimate.go index c03ecf2..514de77 100644 --- a/cmd/c3x/estimate.go +++ b/cmd/c3x/estimate.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "github.com/c3xdev/c3x/internal/calculator" @@ -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: `.=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) @@ -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) } } } @@ -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. diff --git a/cmd/c3x/recommend.go b/cmd/c3x/recommend.go index 2f1cc7f..aa00a37 100644 --- a/cmd/c3x/recommend.go +++ b/cmd/c3x/recommend.go @@ -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 .=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") diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go index 942b126..ea572ef 100644 --- a/internal/domain/domain_test.go +++ b/internal/domain/domain_test.go @@ -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") } } diff --git a/internal/domain/resource.go b/internal/domain/resource.go index 70c00a8..ba9b4fb 100644 --- a/internal/domain/resource.go +++ b/internal/domain/resource.go @@ -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 diff --git a/internal/render/baseline_compat_test.go b/internal/render/baseline_compat_test.go new file mode 100644 index 0000000..dbb9a62 --- /dev/null +++ b/internal/render/baseline_compat_test.go @@ -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) + } +} diff --git a/internal/render/csv.go b/internal/render/csv.go index eb6ca85..4f6506e 100644 --- a/internal/render/csv.go +++ b/internal/render/csv.go @@ -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. diff --git a/internal/render/diff_formats.go b/internal/render/diff_formats.go index b985916..abf2976 100644 --- a/internal/render/diff_formats.go +++ b/internal/render/diff_formats.go @@ -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(), @@ -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(), diff --git a/internal/usage/address_test.go b/internal/usage/address_test.go new file mode 100644 index 0000000..c686165 --- /dev/null +++ b/internal/usage/address_test.go @@ -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) + } +} diff --git a/internal/usage/usage.go b/internal/usage/usage.go index af8bfa5..c997fc2 100644 --- a/internal/usage/usage.go +++ b/internal/usage/usage.go @@ -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" @@ -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)) @@ -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. diff --git a/internal/whatif/address_test.go b/internal/whatif/address_test.go new file mode 100644 index 0000000..0f28f18 --- /dev/null +++ b/internal/whatif/address_test.go @@ -0,0 +1,42 @@ +package whatif_test + +import ( + "testing" + + "github.com/c3xdev/c3x/internal/whatif" +) + +func TestParseTerraformAddresses(t *testing.T) { + cases := []struct { + in string + kind, name string + attr string + legacy bool + }{ + {`aws_instance.web.instance_type=m6i.large`, "aws_instance", "web", "instance_type", false}, + {`aws_instance.web[0].instance_type=m6i.large`, "aws_instance", "web[0]", "instance_type", false}, + {`module.tier["web"].aws_instance.this[0].instance_type=m6i.large`, "aws_instance", `module.tier["web"].this[0]`, "instance_type", false}, + {`module.a.module.b.aws_db_instance.main.instance_class=db.r6g.large`, "aws_db_instance", "module.a.module.b.main", "instance_class", false}, + {`aws_s3_bucket.logs["a.b"].versioning=true`, "aws_s3_bucket", `logs["a.b"]`, "versioning", false}, + // pre-0.3.19 form: still parsed to the same resource, flagged + {`aws_instance.module.tier["web"].this[0].instance_type=m6i.large`, "aws_instance", `module.tier["web"].this[0]`, "instance_type", true}, + } + for _, c := range cases { + ovs, err := whatif.Parse([]string{c.in}) + if err != nil { + t.Errorf("%s: %v", c.in, err) + continue + } + o := ovs[0] + if o.Kind != c.kind || o.Name != c.name || o.Attr != c.attr || o.Legacy != c.legacy { + t.Errorf("%s: got {%s %s %s legacy=%v}, want {%s %s %s legacy=%v}", + c.in, o.Kind, o.Name, o.Attr, o.Legacy, c.kind, c.name, c.attr, c.legacy) + } + } +} + +func TestParseRejectsIncompleteModuleAddress(t *testing.T) { + if _, err := whatif.Parse([]string{`module.tier.aws_instance.instance_type=x`}); err == nil { + t.Error("a module address without a resource name must be rejected") + } +} diff --git a/internal/whatif/whatif.go b/internal/whatif/whatif.go index a8e4ee9..d897f47 100644 --- a/internal/whatif/whatif.go +++ b/internal/whatif/whatif.go @@ -1,4 +1,4 @@ -// Package whatif applies `kind.name.attr=value` CLI overrides to +// Package whatif applies `.=value` CLI overrides to // parsed resources. The overrides land between parser and calculator // — after the IaC source is resolved into [domain.Resource]s but // before the calculator evaluates dimensions — so users can ask @@ -18,15 +18,25 @@ import ( ) // Override is one parsed override directive. It binds an attribute on -// a specific resource (`Kind.Name.Attr`) to a typed value. +// a specific resource to a typed value. The resource is written as its +// Terraform address (`module.web.aws_instance.this[0].instance_type`). type Override struct { Kind string Name string Attr string Value any + // Legacy is set when the address used the pre-0.3.19 form, with the + // kind in front of the module path (aws_instance.module.web.this). + // It still matches; the caller can suggest the new form. + Legacy bool } -// Parse turns a slice of `--what-if kind.name.attr=value` strings +// Address is the override's resource in Terraform address form. +func (o Override) Address() string { + return domain.Reference{Kind: o.Kind, Name: o.Name}.TerraformAddress() +} + +// Parse turns a slice of `--what-if
.=value` strings // into [Override]s. Values are type-coerced: bool first, then int, // then float, then string. The strict type-precedence order keeps // `true` from accidentally becoming the string "true". @@ -45,32 +55,64 @@ func Parse(raws []string) ([]Override, error) { func parseOne(raw string) (Override, error) { eq := strings.Index(raw, "=") if eq < 0 { - return Override{}, fmt.Errorf("--what-if expects kind.name.attr=value, got %q", raw) + return Override{}, fmt.Errorf("--what-if expects .=value, got %q", raw) } lhs, rhs := raw[:eq], raw[eq+1:] - // Split lhs on the LAST dot so attr never accidentally consumes - // dots that belong to `module.X.module.Y.kind.name`. Walk right to - // left and require at least three segments: kind, name, attr. - parts := strings.Split(lhs, ".") + parts := splitAddress(lhs) if len(parts) < 3 { - return Override{}, fmt.Errorf("--what-if lhs %q: need kind.name.attr (>=3 segments)", lhs) + return Override{}, fmt.Errorf("--what-if %q: need ., e.g. aws_instance.web.instance_type", lhs) } attr := parts[len(parts)-1] - // Name is everything between the kind prefix and the trailing attr; - // when there's no module prefix this is just the resource name. - // We treat the first segment as kind even for module-prefixed - // addresses — the calculator never sees the module portion on - // the kind side of Resource.Ref. - kind := parts[0] - name := strings.Join(parts[1:len(parts)-1], ".") + addr := parts[:len(parts)-1] + + // Terraform address: module.[.module....].. + if addr[0] == "module" { + i := 0 + var modules []string + for i+1 < len(addr) && addr[i] == "module" { + modules = append(modules, addr[i], addr[i+1]) + i += 2 + } + if len(addr)-i != 2 { + return Override{}, fmt.Errorf("--what-if %q: expected module......", lhs) + } + name := strings.Join(append(modules, addr[i+1]), ".") + return Override{Kind: addr[i], Name: name, Attr: attr, Value: coerce(rhs)}, nil + } + + // ., or the pre-0.3.19 .module..... return Override{ - Kind: kind, - Name: name, - Attr: attr, - Value: coerce(rhs), + Kind: addr[0], + Name: strings.Join(addr[1:], "."), + Attr: attr, + Value: coerce(rhs), + Legacy: len(addr) > 2 && addr[1] == "module", }, nil } +// splitAddress splits on dots outside [...], so an index or for_each key +// that contains a dot (web["a.b"]) stays one segment. +func splitAddress(s string) []string { + var parts []string + depth, start := 0, 0 + for i := 0; i < len(s); i++ { + switch s[i] { + case '[': + depth++ + case ']': + if depth > 0 { + depth-- + } + case '.': + if depth == 0 { + parts = append(parts, s[start:i]) + start = i + 1 + } + } + } + return append(parts, s[start:]) +} + // coerce narrows a raw value string into the strongest Go type that // fits. Booleans win first so `true` doesn't accidentally become a // string; the catalog's `pick(cond, a, b)` would silently break if diff --git a/testdata/golden/monorepo-with-modules.md b/testdata/golden/monorepo-with-modules.md index 44ebee0..dfd0f77 100644 --- a/testdata/golden/monorepo-with-modules.md +++ b/testdata/golden/monorepo-with-modules.md @@ -1,13 +1,13 @@ ## c3x estimate -### 📦 `aws_db_instance.module.database.primary` — $0/mo +### 📦 `module.database.aws_db_instance.primary` — $0/mo | Dimension | Quantity | Unit rate | Monthly | Source | |---|---:|---:|---:|---| | Database instance | 730 hours | $0 | $0 | stub | | Storage | 200 GB-month | $0 | $0 | stub | -### 📦 `aws_elasticache_cluster.module.cache.main` — $0/mo +### 📦 `module.cache.aws_elasticache_cluster.main` — $0/mo | Dimension | Quantity | Unit rate | Monthly | Source | |---|---:|---:|---:|---| @@ -21,8 +21,8 @@ | Resource | Line | Caveat | |---|---|---| -| `aws_db_instance.module.database.primary` | Database instance | offline stub price, not a real rate | -| `aws_db_instance.module.database.primary` | Storage | offline stub price, not a real rate | -| `aws_elasticache_cluster.module.cache.main` | Cache nodes | offline stub price, not a real rate | +| `module.database.aws_db_instance.primary` | Database instance | offline stub price, not a real rate | +| `module.database.aws_db_instance.primary` | Storage | offline stub price, not a real rate | +| `module.cache.aws_elasticache_cluster.main` | Cache nodes | offline stub price, not a real rate |