Skip to content

Module outputs aren't evaluated: for_each over module.<name>.<output> drops the block #116

Closed

Description

@mocon

When C3X parses HCL, an expression that reads a module output (module.<name>.<output>) never resolves. A resource's for_each/count built from one fails, and the block is dropped. An attribute set from one falls back to the catalog default. This happens even when the output is only a literal.

Repro (c3x 0.3.20, darwin/arm64)

mods/sizes/main.tf:

locals {
  sizes = { prod = { class = "db.m7g.large" } }
}
output "sizes"  { value = local.sizes }
output "queues" { value = ["a", "b"] }

mods/db/main.tf:

variable "class"  { type = string }
variable "queues" { type = list(string) }

resource "aws_db_instance" "x" {
  engine            = "postgres"
  instance_class    = var.class
  allocated_storage = 50
}

resource "aws_sqs_queue" "q" {
  for_each = toset(var.queues)
  name     = each.key
}

root/main.tf:

provider "aws" { region = "eu-west-1" }

module "sizes" { source = "../mods/sizes" }

module "db" {
  source = "../mods/db"
  class  = module.sizes.sizes["prod"].class
  queues = module.sizes.queues
}

resource "aws_db_instance" "direct" {
  engine            = "postgres"
  instance_class    = module.sizes.sizes["prod"].class
  allocated_storage = 50
}

c3x estimate --path root --format json gives:

  • module.db.aws_sqs_queue.q is dropped: WARN for_each evaluation failed; block omitted ... Unsupported attribute: This object does not have an attribute named "queues".
  • module.db.aws_db_instance.x and aws_db_instance.direct are both priced at the catalog default class, with unresolved_attribute: instance_class could not be evaluated from the configuration.

Terraform resolves all three: the queues are a and b, and the class is db.m7g.large.

Where it seems to come from

In internal/parser/terraform at v0.3.20, buildEvalContext (eval_context.go) sets var, local and data, plus extras (count/each) and resource references (resources.go). Nothing sets module. reservedRoot (resource_refs.go) reserves the name, and json_config.go skips output blocks. It looks like a module's outputs are never evaluated, and never exposed to the module that calls it.

Impact

Our roots get their sizing, queue and bucket lists from a shared, input-free module "cells". With C3X, one Environment's platform root comes to $41/month (Infracost: $535), and its workloads root comes to $0 (Infracost: $252). NAT gateways, ECS services, Lambda, CloudFront, SQS and the RDS class are all lost.

Activity

  1. andreacappelletti97 commented on Oct 23, 2026

    @andreacappelletti97
    Contributor

    Thanks for the detailed report and the repro, it made this quick to pin down. Fixed in v0.3.21 (#117).

    Module outputs are now evaluated, and module.<name>.<output> is in scope for module inputs, locals and resources:

    • count and for_each built from a module output now expand;
    • module.x[0].out under count and module.x["key"].out under for_each work;
    • outputs passed up through nested modules work;
    • outputs in .tf.json files are read too.

    Your repro now resolves db.m7g.large for both instances, and q["a"] and q["b"] are both present.

    Upgrade with brew upgrade c3xdev/tap/c3x, or from the release page. If your cells-based roots still show anything off, please reopen with the warning lines and I'll take a look.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions