configs/configupgrade: Convert block-as-attr to dynamic blocks

Users discovered that they could exploit some missing validation in
Terraform v0.11 and prior to treat block types as if they were attributes
and assign dynamic expressions to them, with some significant caveats and
gotchas resulting from the fact that this was never intended to work.

However, since such patterns are in use in the wild we'll convert them
to a dynamic block during upgrade. With only static analysis we must
unfortunately generate a very conservative, ugly dynamic block with
every possible argument set. Users ought to then clean up the generated
configuration after confirming which arguments are actually required.
This commit is contained in:
Martin Atkins 2018-12-01 00:15:26 -08:00
parent f96d702d4f
commit 6596d031d9
12 changed files with 277 additions and 8 deletions

View File

@ -0,0 +1,8 @@
resource "test_instance" "foo" {
network = [
{
cidr_block = "10.1.2.0/24"
},
"${var.baz}"
]
}

View File

@ -0,0 +1,23 @@
resource "test_instance" "foo" {
network {
cidr_block = "10.1.2.0/24"
}
dynamic "network" {
for_each = [var.baz]
content {
# TF-UPGRADE-TODO: The automatic upgrade tool can't predict
# which keys might be set in maps assigned here, so it has
# produced a comprehensive set here. Consider simplifying
# this after confirming which keys can be set in practice.
cidr_block = lookup(network.value, "cidr_block", null)
dynamic "subnet" {
for_each = lookup(network.value, "subnet", [])
content {
number = subnet.value.number
}
}
}
}
}

View File

@ -0,0 +1,3 @@
terraform {
required_version = ">= 0.12"
}

View File

@ -0,0 +1,5 @@
resource "test_instance" "foo" {
network {
subnet = "${var.baz}"
}
}

View File

@ -0,0 +1,15 @@
resource "test_instance" "foo" {
network {
dynamic "subnet" {
for_each = var.baz
content {
# TF-UPGRADE-TODO: The automatic upgrade tool can't predict
# which keys might be set in maps assigned here, so it has
# produced a comprehensive set here. Consider simplifying
# this after confirming which keys can be set in practice.
number = subnet.value.number
}
}
}
}

View File

@ -0,0 +1,3 @@
terraform {
required_version = ">= 0.12"
}

View File

@ -0,0 +1,3 @@
resource "test_instance" "foo" {
network = "${var.baz}"
}

View File

@ -0,0 +1,20 @@
resource "test_instance" "foo" {
dynamic "network" {
for_each = var.baz
content {
# TF-UPGRADE-TODO: The automatic upgrade tool can't predict
# which keys might be set in maps assigned here, so it has
# produced a comprehensive set here. Consider simplifying
# this after confirming which keys can be set in practice.
cidr_block = lookup(network.value, "cidr_block", null)
dynamic "subnet" {
for_each = lookup(network.value, "subnet", [])
content {
number = subnet.value.number
}
}
}
}
}

View File

@ -0,0 +1,3 @@
terraform {
required_version = ">= 0.12"
}

View File

@ -5,7 +5,6 @@ import (
"fmt"
hcl1ast "github.com/hashicorp/hcl/hcl/ast"
hcl1printer "github.com/hashicorp/hcl/hcl/printer"
hcl1token "github.com/hashicorp/hcl/hcl/token"
hcl2 "github.com/hashicorp/hcl2/hcl"
hcl2syntax "github.com/hashicorp/hcl2/hcl/hclsyntax"
@ -184,7 +183,7 @@ func nestedBlockRule(filename string, nestedRules bodyContentRules, an *analysis
}
}
func nestedBlockRuleWithDynamic(filename string, nestedRules bodyContentRules, an *analysis, adhocComments *commentQueue) bodyItemRule {
func nestedBlockRuleWithDynamic(filename string, nestedRules bodyContentRules, nestedSchema *configschema.NestedBlock, an *analysis, adhocComments *commentQueue) bodyItemRule {
return func(buf *bytes.Buffer, blockAddr string, item *hcl1ast.ObjectItem) tfdiags.Diagnostics {
// In Terraform v0.11 it was possible in some cases to trick Terraform
// and providers into accepting HCL's attribute syntax and some HIL
@ -218,10 +217,92 @@ func nestedBlockRuleWithDynamic(filename string, nestedRules bodyContentRules, a
// generate a single "dynamic" block to iterate over the list at
// runtime.
// TODO: Implement this
hcl1printer.Fprint(buf, item)
buf.WriteByte('\n')
return nil
var diags tfdiags.Diagnostics
blockType := item.Keys[0].Token.Value().(string)
labels := make([]string, len(item.Keys)-1)
for i, key := range item.Keys[1:] {
labels[i] = key.Token.Value().(string)
}
var blockItems []hcl1ast.Node
switch val := item.Val.(type) {
case *hcl1ast.ObjectType:
blockItems = append(blockItems, val)
case *hcl1ast.ListType:
for _, node := range val.List {
switch listItem := node.(type) {
case *hcl1ast.ObjectType:
blockItems = append(blockItems, listItem)
default:
// We're going to cheat a bit here and construct a synthetic
// HCL1 list just because that makes our logic
// simpler below where we can just treat all non-objects
// in the same way when producing "dynamic" blocks.
synthList := &hcl1ast.ListType{
List: []hcl1ast.Node{listItem},
Lbrack: listItem.Pos(),
Rbrack: hcl1NodeEndPos(listItem),
}
blockItems = append(blockItems, synthList)
}
}
default:
blockItems = append(blockItems, item.Val)
}
for _, blockItem := range blockItems {
switch ti := blockItem.(type) {
case *hcl1ast.ObjectType:
// If we have an object then we'll pass through its content
// as a block directly. This is the most straightforward mapping
// from the source input, since we know exactly which keys
// are present.
printBlockOpen(buf, blockType, labels, item.LineComment)
bodyDiags := upgradeBlockBody(
filename, fmt.Sprintf("%s.%s", blockAddr, blockType), buf,
ti.List.Items, nestedRules, adhocComments,
)
diags = diags.Append(bodyDiags)
buf.WriteString("}\n")
default:
// For any other sort of value we can't predict what shape it
// will have at runtime, so we must generate a very conservative
// "dynamic" block that tries to assign everything from the
// schema. The result of this is likely to be pretty ugly.
printBlockOpen(buf, "dynamic", []string{blockType}, item.LineComment)
eachSrc, eachDiags := upgradeExpr(blockItem, filename, true, an)
diags = diags.Append(eachDiags)
printAttribute(buf, "for_each", eachSrc, nil)
if nestedSchema.Nesting == configschema.NestingMap {
// This is a pretty odd situation since map-based blocks
// didn't exist prior to Terraform v0.12, but we'll support
// this anyway in case we decide to add support in a later
// SDK release that is still somehow compatible with
// Terraform v0.11.
printAttribute(buf, "labels", []byte(fmt.Sprintf(`[%s.key]`, blockType)), nil)
}
printBlockOpen(buf, "content", nil, nil)
buf.WriteString("# TF-UPGRADE-TODO: The automatic upgrade tool can't predict\n")
buf.WriteString("# which keys might be set in maps assigned here, so it has\n")
buf.WriteString("# produced a comprehensive set here. Consider simplifying\n")
buf.WriteString("# this after confirming which keys can be set in practice.\n\n")
printDynamicBlockBody(buf, blockType, &nestedSchema.Block)
buf.WriteString("}\n")
buf.WriteString("}\n")
diags = diags.Append(&hcl2.Diagnostic{
Severity: hcl2.DiagWarning,
Summary: "Approximate migration of invalid block type assignment",
Detail: fmt.Sprintf("In %s the name %q is a nested block type, but this configuration is exploiting some missing validation rules from Terraform v0.11 and prior to trick Terraform into creating blocks dynamically.\n\nThis has been upgraded to use the new Terraform v0.12 dynamic blocks feature, but since the upgrade tool cannot predict which map keys will be present a fully-comprehensive set has been generated.", blockAddr, blockType),
Subject: hcl1PosRange(filename, blockItem.Pos()).Ptr(),
})
}
}
return diags
}
}
@ -243,7 +324,7 @@ func schemaDefaultBodyRules(filename string, schema *configschema.Block, an *ana
}
for name, blockS := range schema.BlockTypes {
nestedRules := schemaDefaultBodyRules(filename, &blockS.Block, an, adhocComments)
ret[name] = nestedBlockRule(filename, nestedRules, an, adhocComments)
ret[name] = nestedBlockRuleWithDynamic(filename, nestedRules, blockS, an, adhocComments)
}
return ret

View File

@ -20,6 +20,7 @@ import (
"github.com/hashicorp/terraform/addrs"
backendinit "github.com/hashicorp/terraform/backend/init"
"github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/tfdiags"
)
@ -452,6 +453,76 @@ func upgradeBlockBody(filename string, blockAddr string, buf *bytes.Buffer, args
return diags
}
// printDynamicBody prints out a conservative, exhaustive dynamic block body
// for every attribute and nested block in the given schema, for situations
// when a dynamic expression was being assigned to a block type name in input
// configuration and so we can assume it's a list of maps but can't make
// any assumptions about what subset of the schema-specified keys might be
// present in the map values.
func printDynamicBlockBody(buf *bytes.Buffer, iterName string, schema *configschema.Block) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
attrNames := make([]string, 0, len(schema.Attributes))
for name := range schema.Attributes {
attrNames = append(attrNames, name)
}
sort.Strings(attrNames)
for _, name := range attrNames {
attrS := schema.Attributes[name]
if !(attrS.Required || attrS.Optional) { // no Computed-only attributes
continue
}
if attrS.Required {
// For required attributes we can generate a simpler expression
// that just assumes the presence of the key representing the
// attribute value.
printAttribute(buf, name, []byte(fmt.Sprintf(`%s.value.%s`, iterName, name)), nil)
} else {
// Otherwise we must be conservative and generate a conditional
// lookup that will just populate nothing at all if the expected
// key is not present.
printAttribute(buf, name, []byte(fmt.Sprintf(`lookup(%s.value, %q, null)`, iterName, name)), nil)
}
}
blockTypeNames := make([]string, 0, len(schema.BlockTypes))
for name := range schema.BlockTypes {
blockTypeNames = append(blockTypeNames, name)
}
sort.Strings(blockTypeNames)
for i, name := range blockTypeNames {
blockS := schema.BlockTypes[name]
// We'll disregard any block type that consists only of computed
// attributes, since otherwise we'll just create weird empty blocks
// that do nothing except create confusion.
if !schemaHasSettableArguments(&blockS.Block) {
continue
}
if i > 0 || len(attrNames) > 0 {
buf.WriteByte('\n')
}
printBlockOpen(buf, "dynamic", []string{name}, nil)
switch blockS.Nesting {
case configschema.NestingMap:
printAttribute(buf, "for_each", []byte(fmt.Sprintf(`lookup(%s.value, %q, {})`, iterName, name)), nil)
printAttribute(buf, "labels", []byte(fmt.Sprintf(`[%s.key]`, name)), nil)
case configschema.NestingSingle:
printAttribute(buf, "for_each", []byte(fmt.Sprintf(`lookup(%s.value, %q, null) != null ? [%s.value.%s] : []`, iterName, name, iterName, name)), nil)
default:
printAttribute(buf, "for_each", []byte(fmt.Sprintf(`lookup(%s.value, %q, [])`, iterName, name)), nil)
}
printBlockOpen(buf, "content", nil, nil)
moreDiags := printDynamicBlockBody(buf, name, &blockS.Block)
diags = diags.Append(moreDiags)
buf.WriteString("}\n")
buf.WriteString("}\n")
}
return diags
}
func printComments(buf *bytes.Buffer, group *hcl1ast.CommentGroup) {
if group == nil {
return
@ -633,3 +704,17 @@ func passthruBlockTodo(w io.Writer, node hcl1ast.Node, msg string) {
hcl1printer.Fprint(w, node)
w.Write([]byte{'\n', '\n'})
}
func schemaHasSettableArguments(schema *configschema.Block) bool {
for _, attrS := range schema.Attributes {
if attrS.Optional || attrS.Required {
return true
}
}
for _, blockS := range schema.BlockTypes {
if schemaHasSettableArguments(&blockS.Block) {
return true
}
}
return false
}

View File

@ -200,7 +200,27 @@ var testProviders = map[string]providers.Factory{
Nesting: configschema.NestingSet,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"cidr_block": {Type: cty.String, Computed: true},
"cidr_block": {Type: cty.String, Optional: true},
"subnet_cidrs": {Type: cty.Map(cty.String), Computed: true},
},
BlockTypes: map[string]*configschema.NestedBlock{
"subnet": {
Nesting: configschema.NestingSet,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"number": {Type: cty.Number, Required: true},
},
},
},
},
},
},
"addresses": {
Nesting: configschema.NestingSingle,
Block: configschema.Block{
Attributes: map[string]*configschema.Attribute{
"ipv4": {Type: cty.String, Computed: true},
"ipv6": {Type: cty.String, Computed: true},
},
},
},