diff --git a/CHANGELOG.md b/CHANGELOG.md index 404119a..873321c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 8fd2697..d2bff3d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/cmd/c3x/assumed_count_test.go b/cmd/c3x/assumed_count_test.go new file mode 100644 index 0000000..6539773 --- /dev/null +++ b/cmd/c3x/assumed_count_test.go @@ -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) + } +} diff --git a/cmd/c3x/strict.go b/cmd/c3x/strict.go index cf0c5f0..bc04048 100644 --- a/cmd/c3x/strict.go +++ b/cmd/c3x/strict.go @@ -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. diff --git a/internal/calculator/caveats.go b/internal/calculator/caveats.go index 4573cc3..2f220b2 100644 --- a/internal/calculator/caveats.go +++ b/internal/calculator/caveats.go @@ -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 diff --git a/internal/calculator/caveats_test.go b/internal/calculator/caveats_test.go index 30fe071..7a50d65 100644 --- a/internal/calculator/caveats_test.go +++ b/internal/calculator/caveats_test.go @@ -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()) + } +} diff --git a/internal/calculator/engine.go b/internal/calculator/engine.go index 6e52a75..678dfc5 100644 --- a/internal/calculator/engine.go +++ b/internal/calculator/engine.go @@ -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 } diff --git a/internal/domain/caveat.go b/internal/domain/caveat.go index 919e724..170adf9 100644 --- a/internal/domain/caveat.go +++ b/internal/domain/caveat.go @@ -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 diff --git a/internal/domain/resource.go b/internal/domain/resource.go index a8e429c..70c00a8 100644 --- a/internal/domain/resource.go +++ b/internal/domain/resource.go @@ -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. diff --git a/internal/parser/terraform/data.go b/internal/parser/terraform/data.go index bf4fcc1..db6b825 100644 --- a/internal/parser/terraform/data.go +++ b/internal/parser/terraform/data.go @@ -1,6 +1,7 @@ package terraform import ( + "context" "fmt" "log/slog" "sort" @@ -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. // @@ -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 { diff --git a/internal/parser/terraform/data_placeholders_test.go b/internal/parser/terraform/data_placeholders_test.go index f725405..2ad9533 100644 --- a/internal/parser/terraform/data_placeholders_test.go +++ b/internal/parser/terraform/data_placeholders_test.go @@ -56,12 +56,14 @@ func TestNATGatewayPerAvailabilityZone(t *testing.T) { if slices.Contains(nat.Unresolved, "connectivity_type") { t.Errorf("unresolved %v; connectivity_type doesn't depend on a placeholder", nat.Unresolved) } - // The count assumption is a warning naming the resource, the data - // source and the value assumed. - for _, want := range []string{"aws_nat_gateway.nat", "data.aws_availability_zones.available.names", "eu-west-1a"} { - if !strings.Contains(logs, want) { - t.Errorf("warning lacks %q; logs:\n%s", want, logs) - } + // The count assumption is recorded on the instance, naming the data + // source and the value assumed, for the assumed_count caveat. + if want := "data.aws_availability_zones.available.names = [eu-west-1a, eu-west-1b, eu-west-1c]"; nat.AssumedCount != want { + t.Errorf("AssumedCount = %q, want %q", nat.AssumedCount, want) + } + // The caveat says it where the number is; the log stays quiet. + if strings.Contains(logs, "aws_nat_gateway.nat") { + t.Errorf("unexpected warning for the count; logs:\n%s", logs) } } @@ -91,7 +93,7 @@ func TestPlaceholderTracedThroughLocalsAndModules(t *testing.T) { azs = local.azs } `) - got, logs := parseWithLogs(t, dir) + got, _ := parseWithLogs(t, dir) if len(got) != 2 { t.Fatalf("got %d subnets, want 2", len(got)) } @@ -99,8 +101,8 @@ func TestPlaceholderTracedThroughLocalsAndModules(t *testing.T) { if !slices.Contains(s.Unresolved, "availability_zone") || slices.Contains(s.Unresolved, "cidr_block") { t.Errorf("unresolved = %v; want availability_zone (from each.key) only", s.Unresolved) } - if !strings.Contains(logs, "module.vpc.aws_subnet.private") { - t.Errorf("no for_each warning for module.vpc.aws_subnet.private; logs:\n%s", logs) + if !strings.Contains(s.AssumedCount, "data.aws_availability_zones.available.names") { + t.Errorf("AssumedCount = %q; the for_each rests on the zones placeholder", s.AssumedCount) } } @@ -129,13 +131,18 @@ func TestRegionAndIdentityPlaceholders(t *testing.T) { machine_type = "e2-small" } `) - got, logs := parseWithLogs(t, dir) + got, _ := parseWithLogs(t, dir) if len(got) != 4 { t.Fatalf("got %v; want syd[0], acct[0], vm[0], vm[1]", names(got)) } - for _, want := range []string{"data.aws_region.current.name", "data.aws_caller_identity.me.account_id", "data.google_client_config.cfg.region"} { - if !strings.Contains(logs, want) { - t.Errorf("no warning mentions %s; logs:\n%s", want, logs) + by := byName(got) + for name, want := range map[string]string{ + "syd[0]": "data.aws_region.current.name = ap-southeast-2", + "acct[0]": "data.aws_caller_identity.me.account_id = ", + "vm[1]": "data.google_client_config.cfg.region = europe-west4", + } { + if !strings.HasPrefix(by[name].AssumedCount, want) { + t.Errorf("%s: AssumedCount = %q, want it to start with %q", name, by[name].AssumedCount, want) } } } @@ -169,3 +176,85 @@ func TestDataLiteralsBeatPlaceholders(t *testing.T) { t.Errorf("got %v; aws_ami has no placeholder, so y is still omitted", names(got)) } } + +// A count over the placeholder zones records the assumption on every +// instance, for the calculator's assumed_count caveat; a literal count in +// the same configuration records none. +func TestAssumedCountRecordedOnInstances(t *testing.T) { + t.Parallel() + dir := t.TempDir() + write(t, dir, "main.tf", ` + provider "aws" { region = "us-east-1" } + data "aws_availability_zones" "available" {} + resource "aws_nat_gateway" "nat" { + count = length(data.aws_availability_zones.available.names) + } + resource "aws_eip" "nat" { + for_each = toset(data.aws_availability_zones.available.names) + domain = "vpc" + } + resource "aws_instance" "web" { + count = 2 + instance_type = "t3.micro" + } + resource "aws_instance" "one" { + instance_type = "t3.micro" + } + `) + got, _ := parseWithLogs(t, dir) + want := "data.aws_availability_zones.available.names = [us-east-1a, us-east-1b, us-east-1c]" + by := byName(got) + for _, n := range []string{"nat[0]", "nat[1]", "nat[2]", `nat["us-east-1b"]`} { + if got := by[n].AssumedCount; got != want { + t.Errorf("%s: AssumedCount = %q, want %q", n, got, want) + } + } + for _, n := range []string{"web[0]", "web[1]", "one"} { + r, ok := by[n] + if !ok { + t.Fatalf("%s missing; got %v", n, names(got)) + } + if got := r.AssumedCount; got != "" { + t.Errorf("%s: AssumedCount = %q; a literal count assumes nothing", n, got) + } + } +} + +// A module-level count over a placeholder makes every resource in the +// module an assumption, even one with no count of its own. +func TestAssumedCountPropagatesFromModuleCount(t *testing.T) { + t.Parallel() + dir := t.TempDir() + mod := filepath.Join(dir, "zone") + if err := os.Mkdir(mod, 0o755); err != nil { + t.Fatal(err) + } + write(t, mod, "main.tf", ` + variable "az" {} + resource "aws_nat_gateway" "this" {} + `) + write(t, dir, "main.tf", ` + data "aws_availability_zones" "available" {} + module "zone" { + source = "./zone" + count = length(data.aws_availability_zones.available.names) + az = data.aws_availability_zones.available.names[count.index] + } + module "fixed" { + source = "./zone" + count = 1 + az = "us-east-1a" + } + `) + got, _ := parseWithLogs(t, dir) + by := byName(got) + if len(got) != 4 { + t.Fatalf("got %v; want three zone instances and one fixed", names(got)) + } + if a := by["module.zone[2].this"].AssumedCount; !strings.Contains(a, "data.aws_availability_zones.available.names = [us-east-1a") { + t.Errorf("module.zone[2].this AssumedCount = %q", a) + } + if r, ok := by["module.fixed[0].this"]; !ok || r.AssumedCount != "" { + t.Errorf("module.fixed[0].this present=%v AssumedCount=%q; want present, empty", ok, r.AssumedCount) + } +} diff --git a/internal/parser/terraform/modules.go b/internal/parser/terraform/modules.go index e86633c..5603e06 100644 --- a/internal/parser/terraform/modules.go +++ b/internal/parser/terraform/modules.go @@ -252,6 +252,9 @@ func (m moduleExpander) expandInstance( resources: child.resources, regions: childRegions, logger: logger, + // A module instance that exists because of a placeholder + // count / for_each makes every resource under it a guess too. + assumedCount: joinAssumed(parent.assumedCount, inst.assumed), } if err := emitResources(env, child.sources, childPrefix, m.out); err != nil { return err diff --git a/internal/parser/terraform/resources.go b/internal/parser/terraform/resources.go index be2e069..7f6ec09 100644 --- a/internal/parser/terraform/resources.go +++ b/internal/parser/terraform/resources.go @@ -23,6 +23,11 @@ type moduleEnv struct { resources map[string]cty.Value regions providerRegions logger *slog.Logger + // assumedCount is set when this module instance exists because of a + // module-level count / for_each computed from a data source + // placeholder; every resource under it inherits it (see + // domain.Resource.AssumedCount). + assumedCount string } // evalContext builds the context for one expression. extras are the @@ -84,7 +89,9 @@ func emitOne( if err != nil { return fmt.Errorf("%s.%s%s: %w", kind, name, inst.suffix, err) } - *out = append(*out, makeResource(kind, namePrefix+name+inst.suffix, attrs, env.regions.forResource(kind, body, ctx), unresolved)) + r := makeResource(kind, namePrefix+name+inst.suffix, attrs, env.regions.forResource(kind, body, ctx), unresolved) + r.AssumedCount = joinAssumed(env.assumedCount, inst.assumed) + *out = append(*out, r) } return nil } @@ -94,12 +101,16 @@ func emitOne( type instance struct { suffix string extras map[string]cty.Value + // assumed describes the data source placeholders the count / + // for_each was computed from; empty when it rests on none. + assumed string } // expandInstances evaluates a resource or module block's count / // for_each. ok is false when the meta-argument can't be evaluated, in // which case the block is omitted (with a warning). A count or for_each -// computed from a data source placeholder is expanded, and warned about. +// computed from a data source placeholder is expanded and recorded on +// every instance (instance.assumed). func expandInstances(env moduleEnv, body *hclsyntax.Body, srcPath, address string) ([]instance, bool) { countAttr := body.Attributes["count"] foreachAttr := body.Attributes["for_each"] @@ -119,8 +130,9 @@ func expandInstances(env moduleEnv, body *hclsyntax.Body, srcPath, address strin if !ok { return nil, false } - if len(deps) > 0 { - warnPlaceholders(logger, "count", address, deps) + assumed := describePlaceholders(deps) + if assumed != "" { + logPlaceholders(logger, slog.LevelDebug, "count", address, assumed) } // Bounded before allocating: count = 1e9 must not build a slice. if count > maxInstancesPerResource { @@ -129,7 +141,8 @@ func expandInstances(env moduleEnv, body *hclsyntax.Body, srcPath, address strin out := make([]instance, count) for i := range out { out[i] = instance{ - suffix: fmt.Sprintf("[%d]", i), + suffix: fmt.Sprintf("[%d]", i), + assumed: assumed, extras: map[string]cty.Value{ "count": cty.ObjectVal(map[string]cty.Value{"index": cty.NumberIntVal(int64(i))}), }, @@ -146,8 +159,9 @@ func expandInstances(env moduleEnv, body *hclsyntax.Body, srcPath, address strin return nil, false } val, deps := stripPlaceholders(val) - if len(deps) > 0 { - warnPlaceholders(logger, "for_each", address, deps) + assumed := describePlaceholders(deps) + if assumed != "" { + logPlaceholders(logger, slog.LevelDebug, "for_each", address, assumed) } pairs := foreachPairs(val) out := make([]instance, len(pairs)) @@ -156,7 +170,8 @@ func expandInstances(env moduleEnv, body *hclsyntax.Body, srcPath, address strin // itself a guess: carry the marks so attributes computed // from each.key / each.value are reported unresolved. out[i] = instance{ - suffix: fmt.Sprintf("[%q]", p.Key), + suffix: fmt.Sprintf("[%q]", p.Key), + assumed: assumed, extras: map[string]cty.Value{ "each": cty.ObjectVal(map[string]cty.Value{ "key": withPlaceholders(cty.StringVal(p.Key), deps), @@ -369,7 +384,7 @@ func (x *attrExtractor) dynamic(block *hclsyntax.Block, ctx *hcl.EvalContext, wh } coll, deps := stripPlaceholders(coll) if len(deps) > 0 { - warnPlaceholders(x.logger, "dynamic block for_each", where+"."+label, deps) + logPlaceholders(x.logger, slog.LevelWarn, "dynamic block for_each", where+"."+label, describePlaceholders(deps)) } if coll.IsNull() || !coll.IsKnown() || !coll.CanIterateElements() { return nil