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

### Fixed

- Module outputs are now evaluated (#116). An expression reading
`module.<name>.<output>` never resolved: a `count` or `for_each` built
from one dropped the block, and an attribute set from one was priced at
the catalog default. Each module call is now resolved before the
resources that read it, its `output` blocks are evaluated in its own
scope, and the values are in scope for module inputs, locals and
resources, including `module.x[0].out` under `count`,
`module.x["key"].out` under `for_each`, and outputs passed up through
nested modules. Outputs in `.tf.json` files are read too.

- Homebrew: the cask now clears the quarantine flag after install. Homebrew
quarantines what a cask downloads, and macOS killed the un-notarized
binary on launch (exit 137), so `brew install c3xdev/tap/c3x` gave a c3x
Expand Down
22 changes: 15 additions & 7 deletions internal/parser/terraform/json_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import (
// schemas. Translating keeps one AST type throughout and needs no schema:
//
// - The top-level structure is fixed by the language (resource, data,
// module, provider, variable, locals), so it becomes native blocks.
// module, provider, variable, locals, output), so it becomes native
// blocks.
// - Inside a resource, a JSON object or array of objects is written as
// an attribute: `root_block_device = { volume_size = 50 }`. That
// evaluates to exactly the attribute shape a literal nested block
Expand All @@ -41,9 +42,9 @@ import (
// references: a resource's `provider`, a module's `providers` map,
// a variable's `type`, and a dynamic block's `iterator`.
//
// Skipped, as irrelevant to cost: output, terraform, moved, import, check
// and removed blocks, and depends_on, lifecycle, provisioner and
// connection inside resources. Anything else this can't represent is
// Skipped, as irrelevant to cost: terraform, moved, import, check and
// removed blocks, everything in an output but its value, and depends_on,
// lifecycle, provisioner and connection inside resources. Anything else this can't represent is
// skipped with a warning naming the file and key, never silently.
//
// Diagnostics for a JSON file cite positions in the translated source,
Expand Down Expand Up @@ -144,7 +145,8 @@ const (
bodyResource bodyKind = iota // resource, data, and dynamic content
bodyModule
bodyVariable
bodyPlain // provider, locals: no special keys
bodyOutput // only value is kept
bodyPlain // provider, locals: no special keys
)

func (t *jsonTranslator) skip(reason, key string) {
Expand All @@ -157,7 +159,7 @@ func (t *jsonTranslator) file(obj *jsonObject) {
for i, key := range obj.keys {
val := obj.vals[i]
switch key {
case "//", "output", "terraform", "moved", "import", "check", "removed":
case "//", "terraform", "moved", "import", "check", "removed":
// Comments, and blocks that declare nothing c3x prices.
case "resource", "data":
t.typedBlocks(key, val)
Expand All @@ -167,6 +169,8 @@ func (t *jsonTranslator) file(obj *jsonObject) {
t.namedBlocks(key, val, bodyPlain)
case "variable":
t.namedBlocks(key, val, bodyVariable)
case "output":
t.namedBlocks(key, val, bodyOutput)
case "locals":
t.forEachObject(key, val, func(o *jsonObject) {
t.b.WriteString("locals {\n")
Expand Down Expand Up @@ -211,7 +215,7 @@ func (t *jsonTranslator) typedBlocks(blockType string, val any) {
})
}

// namedBlocks writes module / provider / variable blocks: {"<name>": body},
// namedBlocks writes module / provider / variable / output blocks: {"<name>": body},
// where body may be an array (several provider configurations).
func (t *jsonTranslator) namedBlocks(blockType string, val any, kind bodyKind) {
t.forEachObject(blockType, val, func(names *jsonObject) {
Expand Down Expand Up @@ -283,6 +287,10 @@ func (t *jsonTranslator) attributes(obj *jsonObject, kind bodyKind, where string
t.providersMap(val, addr)
continue
}
case bodyOutput:
if key != "value" {
continue
}
case bodyVariable:
switch key {
case "type":
Expand Down
14 changes: 11 additions & 3 deletions internal/parser/terraform/locals.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,19 @@ import (
// longest local→local reference chain. In practice that's < 5 and the
// per-attribute work is cheap, so we don't bother with a smarter
// dependency-graph topological sort.
func resolveLocals(scope evalScope, sources []sourceFile, vars map[string]cty.Value, data cty.Value, logger *slog.Logger) map[string]cty.Value {
//
// modules is the module.<name> scope: the outputs of the module calls
// resolved so far. Locals are resolved again as each call is resolved, so
// a nil logger skips the warnings for those intermediate passes.
func resolveLocals(scope evalScope, sources []sourceFile, vars map[string]cty.Value, data, modules cty.Value, logger *slog.Logger) map[string]cty.Value {
extras := map[string]cty.Value{"module": modules}
pending := collectLocalAttributes(sources)
resolved := map[string]cty.Value{}
for {
progressed := false
var still []localAttr
for _, la := range pending {
ctx := scope.evalContext(asObject(vars), asObject(resolved), data, nil)
ctx := scope.evalContext(asObject(vars), asObject(resolved), data, extras)
val, diags := la.Expr.Value(ctx)
if diags.HasErrors() {
still = append(still, la)
Expand All @@ -37,10 +42,13 @@ func resolveLocals(scope evalScope, sources []sourceFile, vars map[string]cty.Va
break
}
}
if logger == nil {
return resolved
}
// Whatever never resolved: report it if the cause is a function we
// don't implement, since every resource reading that local is affected.
for _, la := range pending {
_, diags := la.Expr.Value(scope.evalContext(asObject(vars), asObject(resolved), data, nil))
_, diags := la.Expr.Value(scope.evalContext(asObject(vars), asObject(resolved), data, extras))
warnUnknownFunctions(diags, logger, "local."+la.Name)
}
return resolved
Expand Down
163 changes: 163 additions & 0 deletions internal/parser/terraform/module_outputs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package terraform

import (
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
)

// moduleOutputs evaluates a module instance's `output "x" { value = ... }`
// blocks in its own scope. An output that can't be evaluated is left out,
// so reading it fails in the caller exactly as an unknown attribute does.
func moduleOutputs(env moduleEnv, sources []sourceFile) cty.Value {
ctx := env.evalContext(nil)
out := map[string]cty.Value{}
for _, src := range sources {
for _, block := range src.Body.Blocks {
if block.Type != "output" || len(block.Labels) == 0 {
continue
}
attr, ok := block.Body.Attributes["value"]
if !ok {
continue
}
val, diags := attr.Expr.Value(ctx)
if diags.HasErrors() {
env.logger.Debug("module output evaluation failed",
"output", block.Labels[0], "dir", env.scope.moduleDir,
"diags", formatDiags(diags))
continue
}
out[block.Labels[0]] = val
}
}
return cty.ObjectVal(out)
}

// callOrder orders a configuration's module calls so that a call reading
// module.X, directly or through a local, comes after the call X. Calls
// that depend on nothing keep their source order. A cycle, which
// Terraform rejects unless it runs through outputs that don't depend on
// each other, is broken at the first remaining call in source order.
//
// localsReadModules reports whether any local reads a module output, in
// which case the locals are resolved again after each call.
func callOrder(calls []*moduleCall, sources []sourceFile) (order []int, localsReadModules bool) {
localDeps := localModuleDeps(sources)
for _, d := range localDeps {
if len(d) > 0 {
localsReadModules = true
break
}
}
names := map[string]bool{}
deps := make([]map[string]bool, len(calls))
for i, c := range calls {
names[c.Labels[0]] = true
deps[i] = map[string]bool{}
for _, attr := range c.Body.Attributes {
addModuleDeps(deps[i], attr.Expr.Variables(), localDeps)
}
delete(deps[i], c.Labels[0])
}
done := map[string]bool{}
placed := make([]bool, len(calls))
for len(order) < len(calls) {
next := -1
for i := range calls {
if placed[i] {
continue
}
if next < 0 {
next = i
}
if ready(deps[i], names, done) {
next = i
break
}
}
placed[next] = true
order = append(order, next)
done[calls[next].Labels[0]] = true
}
return order, localsReadModules
}

// ready reports whether every module call in deps has been resolved;
// names the configuration doesn't call can never be, so they don't wait.
func ready(deps, names, done map[string]bool) bool {
for d := range deps {
if names[d] && !done[d] {
return false
}
}
return true
}

// localModuleDeps maps each local to the module calls it reads, directly
// or through other locals.
func localModuleDeps(sources []sourceFile) map[string]map[string]bool {
attrs := collectLocalAttributes(sources)
direct := map[string]map[string]bool{}
refs := map[string][]string{}
for _, la := range attrs {
direct[la.Name] = map[string]bool{}
for _, t := range la.Expr.Variables() {
switch t.RootName() {
case "module":
if name, ok := secondStep(t); ok {
direct[la.Name][name] = true
}
case "local":
if name, ok := secondStep(t); ok {
refs[la.Name] = append(refs[la.Name], name)
}
}
}
}
// Close over local → local references; chains are short.
for changed := true; changed; {
changed = false
for name, deps := range direct {
for _, r := range refs[name] {
for d := range direct[r] {
if !deps[d] {
deps[d] = true
changed = true
}
}
}
}
}
return direct
}

// addModuleDeps adds the module calls a set of traversals reads, directly
// or through locals, to deps.
func addModuleDeps(deps map[string]bool, traversals []hcl.Traversal, localDeps map[string]map[string]bool) {
for _, t := range traversals {
name, ok := secondStep(t)
if !ok {
continue
}
switch t.RootName() {
case "module":
deps[name] = true
case "local":
for d := range localDeps[name] {
deps[d] = true
}
}
}
}

// secondStep returns the attribute name after a traversal's root:
// "sizes" for module.sizes.x.
func secondStep(t hcl.Traversal) (string, bool) {
if len(t) < 2 {
return "", false
}
if a, ok := t[1].(hcl.TraverseAttr); ok {
return a.Name, true
}
return "", false
}
Loading
Loading