terraform/configs/configupgrade/upgrade_expr.go

554 lines
16 KiB
Go

package configupgrade
import (
"bytes"
"fmt"
"log"
"strconv"
"strings"
hcl2 "github.com/hashicorp/hcl2/hcl"
hcl2syntax "github.com/hashicorp/hcl2/hcl/hclsyntax"
"github.com/zclconf/go-cty/cty"
hcl1ast "github.com/hashicorp/hcl/hcl/ast"
hcl1printer "github.com/hashicorp/hcl/hcl/printer"
hcl1token "github.com/hashicorp/hcl/hcl/token"
"github.com/hashicorp/hil"
hilast "github.com/hashicorp/hil/ast"
"github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/tfdiags"
)
func upgradeExpr(val interface{}, filename string, interp bool, an *analysis) ([]byte, tfdiags.Diagnostics) {
var buf bytes.Buffer
var diags tfdiags.Diagnostics
// "val" here can be either a hcl1ast.Node or a hilast.Node, since both
// of these correspond to expressions in HCL2. Therefore we need to
// comprehensively handle every possible HCL1 *and* HIL AST node type
// and, at minimum, print it out as-is in HCL2 syntax.
Value:
switch tv := val.(type) {
case *hcl1ast.LiteralType:
return upgradeExpr(tv.Token, filename, interp, an)
case hcl1token.Token:
litVal := tv.Value()
switch tv.Type {
case hcl1token.STRING:
if !interp {
// Easy case, then.
printQuotedString(&buf, litVal.(string))
break
}
hilNode, err := hil.Parse(litVal.(string))
if err != nil {
diags = diags.Append(&hcl2.Diagnostic{
Severity: hcl2.DiagError,
Summary: "Invalid interpolated string",
Detail: fmt.Sprintf("Interpolation parsing failed: %s", err),
Subject: hcl1PosRange(filename, tv.Pos).Ptr(),
})
}
interpSrc, interpDiags := upgradeExpr(hilNode, filename, interp, an)
buf.Write(interpSrc)
diags = diags.Append(interpDiags)
case hcl1token.HEREDOC:
// TODO: Implement
panic("HEREDOC not supported yet")
case hcl1token.BOOL:
if litVal.(bool) {
buf.WriteString("true")
} else {
buf.WriteString("false")
}
default:
// For everything else (NUMBER, FLOAT) we'll just pass through the given bytes verbatim.
buf.WriteString(tv.Text)
}
case *hcl1ast.ListType:
multiline := tv.Lbrack.Line != tv.Rbrack.Line
buf.WriteString("[")
if multiline {
buf.WriteString("\n")
}
for i, node := range tv.List {
src, moreDiags := upgradeExpr(node, filename, interp, an)
diags = diags.Append(moreDiags)
buf.Write(src)
if multiline {
buf.WriteString(",\n")
} else if i < len(tv.List)-1 {
buf.WriteString(", ")
}
}
buf.WriteString("]")
case *hcl1ast.ObjectType:
buf.WriteString("{\n")
for _, item := range tv.List.Items {
if len(item.Keys) != 1 {
diags = diags.Append(&hcl2.Diagnostic{
Severity: hcl2.DiagError,
Summary: "Invalid map element",
Detail: "A map element may not have any block-style labels.",
Subject: hcl1PosRange(filename, item.Pos()).Ptr(),
})
continue
}
keySrc, moreDiags := upgradeExpr(item.Keys[0].Token, filename, interp, an)
diags = diags.Append(moreDiags)
valueSrc, moreDiags := upgradeExpr(item.Val, filename, interp, an)
diags = diags.Append(moreDiags)
buf.Write(keySrc)
buf.WriteString(" = ")
buf.Write(valueSrc)
buf.WriteString("\n")
}
buf.WriteString("}")
case hcl1ast.Node:
// If our more-specific cases above didn't match this then we'll
// ask the hcl1printer package to print the expression out
// itself, and assume it'll still be valid in HCL2.
// (We should rarely end up here, since our cases above should
// be comprehensive.)
log.Printf("[TRACE] configupgrade: Don't know how to upgrade %T as expression, so just passing it through as-is", tv)
hcl1printer.Fprint(&buf, tv)
case *hilast.LiteralNode:
switch tl := tv.Value.(type) {
case string:
// This shouldn't generally happen because literal strings are
// always wrapped in hilast.Output in HIL, but we'll allow it anyway.
printQuotedString(&buf, tl)
case int:
buf.WriteString(strconv.Itoa(tl))
case float64:
buf.WriteString(strconv.FormatFloat(tl, 'f', 64, 64))
case bool:
if tl {
buf.WriteString("true")
} else {
buf.WriteString("false")
}
}
case *hilast.VariableAccess:
// In HIL a variable access is just a single string which might contain
// a mixture of identifiers, dots, integer indices, and splat expressions.
// All of these concepts were formerly interpreted by Terraform itself,
// rather than by HIL. We're going to process this one chunk at a time
// here so we can normalize and introduce some newer syntax where it's
// safe to do so.
parts := strings.Split(tv.Name, ".")
parts = upgradeTraversalParts(parts, an) // might add/remove/change parts
first, remain := parts[0], parts[1:]
buf.WriteString(first)
seenSplat := false
for _, part := range remain {
if part == "*" {
seenSplat = true
buf.WriteString(".*")
continue
}
// Other special cases apply only if we've not previously
// seen a splat expression marker, since attribute vs. index
// syntax have different interpretations after a simple splat.
if !seenSplat {
if v, err := strconv.Atoi(part); err == nil {
// Looks like it's old-style index traversal syntax foo.0.bar
// so we'll replace with canonical index syntax foo[0].bar.
fmt.Fprintf(&buf, "[%d]", v)
continue
}
if !hcl2syntax.ValidIdentifier(part) {
// This should be rare since HIL's identifier syntax is _close_
// to HCL2's, but we'll get here if one of the intervening
// parts is not a valid identifier in isolation, since HIL
// did not consider these to be separate identifiers.
// e.g. foo.1bar would be invalid in HCL2; must instead be foo["1bar"].
buf.WriteByte('[')
printQuotedString(&buf, part)
buf.WriteByte(']')
continue
}
}
buf.WriteByte('.')
buf.WriteString(part)
}
case *hilast.Arithmetic:
op, exists := hilArithmeticOpSyms[tv.Op]
if !exists {
panic(fmt.Errorf("arithmetic node with unsupported operator %#v", tv.Op))
}
lhsExpr := tv.Exprs[0]
rhsExpr := tv.Exprs[1]
lhsSrc, exprDiags := upgradeExpr(lhsExpr, filename, true, an)
diags = diags.Append(exprDiags)
rhsSrc, exprDiags := upgradeExpr(rhsExpr, filename, true, an)
diags = diags.Append(exprDiags)
// HIL's AST represents -foo as (0 - foo), so we'll recognize
// that here and normalize it back.
if tv.Op == hilast.ArithmeticOpSub && len(lhsSrc) == 1 && lhsSrc[0] == '0' {
buf.WriteString("-")
buf.Write(rhsSrc)
break
}
buf.Write(lhsSrc)
buf.WriteString(op)
buf.Write(rhsSrc)
case *hilast.Call:
name := tv.Func
args := tv.Args
argExprs := make([][]byte, len(args))
multiline := false
totalLen := 0
for i, arg := range args {
if i > 0 {
totalLen += 2
}
exprSrc, exprDiags := upgradeExpr(arg, filename, true, an)
diags = diags.Append(exprDiags)
argExprs[i] = exprSrc
if bytes.Contains(exprSrc, []byte{'\n'}) {
// If any of our arguments are multi-line then we'll also be multiline
multiline = true
}
totalLen += len(exprSrc)
}
if totalLen > 60 { // heuristic, since we don't know here how indented we are already
multiline = true
}
// Some functions are now better expressed as native language constructs.
// These cases will return early if they emit anything, or otherwise
// fall through to the default emitter.
switch name {
case "list":
// Should now use tuple constructor syntax
buf.WriteByte('[')
if multiline {
buf.WriteByte('\n')
}
for i, exprSrc := range argExprs {
buf.Write(exprSrc)
if multiline {
buf.WriteString(",\n")
} else {
if i < len(args)-1 {
buf.WriteString(", ")
}
}
}
buf.WriteByte(']')
break Value
case "map":
// Should now use object constructor syntax, but we can only
// achieve that if the call is valid, which requires an even
// number of arguments.
if len(argExprs) == 0 {
buf.WriteString("{}")
break Value
} else if len(argExprs)%2 == 0 {
buf.WriteString("{\n")
for i := 0; i < len(argExprs); i += 2 {
k := argExprs[i]
v := argExprs[i+1]
buf.Write(k)
buf.WriteString(" = ")
buf.Write(v)
buf.WriteByte('\n')
}
buf.WriteByte('}')
break Value
}
case "lookup":
// A lookup call with only two arguments is equivalent to native
// index syntax. (A third argument would specify a default value,
// so calls like that must be left alone.)
// (Note that we can't safely do this for element(...) because
// the user may be relying on its wraparound behavior.)
if len(argExprs) == 2 {
buf.Write(argExprs[0])
buf.WriteByte('[')
buf.Write(argExprs[1])
buf.WriteByte(']')
break Value
}
}
buf.WriteString(name)
buf.WriteByte('(')
if multiline {
buf.WriteByte('\n')
}
for i, exprSrc := range argExprs {
buf.Write(exprSrc)
if multiline {
buf.WriteString(",\n")
} else {
if i < len(args)-1 {
buf.WriteString(", ")
}
}
}
buf.WriteByte(')')
case *hilast.Conditional:
condSrc, exprDiags := upgradeExpr(tv.CondExpr, filename, true, an)
diags = diags.Append(exprDiags)
trueSrc, exprDiags := upgradeExpr(tv.TrueExpr, filename, true, an)
diags = diags.Append(exprDiags)
falseSrc, exprDiags := upgradeExpr(tv.FalseExpr, filename, true, an)
diags = diags.Append(exprDiags)
buf.Write(condSrc)
buf.WriteString(" ? ")
buf.Write(trueSrc)
buf.WriteString(" : ")
buf.Write(falseSrc)
case *hilast.Index:
targetSrc, exprDiags := upgradeExpr(tv.Target, filename, true, an)
diags = diags.Append(exprDiags)
keySrc, exprDiags := upgradeExpr(tv.Key, filename, true, an)
diags = diags.Append(exprDiags)
buf.Write(targetSrc)
buf.WriteString("[")
buf.Write(keySrc)
buf.WriteString("]")
case *hilast.Output:
if len(tv.Exprs) == 1 {
item := tv.Exprs[0]
naked := true
if lit, ok := item.(*hilast.LiteralNode); ok {
if _, ok := lit.Value.(string); ok {
naked = false
}
}
if naked {
// If there's only one expression and it isn't a literal string
// then we'll just output it naked, since wrapping a single
// expression in interpolation is no longer idiomatic.
interped, interpDiags := upgradeExpr(item, filename, true, an)
diags = diags.Append(interpDiags)
buf.Write(interped)
break
}
}
buf.WriteString(`"`)
for _, item := range tv.Exprs {
if lit, ok := item.(*hilast.LiteralNode); ok {
if litStr, ok := lit.Value.(string); ok {
printStringLiteralFromHILOutput(&buf, litStr)
continue
}
}
interped, interpDiags := upgradeExpr(item, filename, true, an)
diags = diags.Append(interpDiags)
buf.WriteString("${")
buf.Write(interped)
buf.WriteString("}")
}
buf.WriteString(`"`)
case hilast.Node:
// Nothing reasonable we can do here, so we should've handled all of
// the possibilities above.
panic(fmt.Errorf("upgradeExpr doesn't handle HIL node type %T", tv))
default:
// If we end up in here then the caller gave us something completely invalid.
panic(fmt.Errorf("upgradeExpr on unsupported type %T", val))
}
return buf.Bytes(), diags
}
func upgradeTraversalExpr(val interface{}, filename string, an *analysis) ([]byte, tfdiags.Diagnostics) {
if lit, ok := val.(*hcl1ast.LiteralType); ok && lit.Token.Type == hcl1token.STRING {
trStr := lit.Token.Value().(string)
trSrc := []byte(trStr)
_, trDiags := hcl2syntax.ParseTraversalAbs(trSrc, "", hcl2.Pos{})
if !trDiags.HasErrors() {
return trSrc, nil
}
}
return upgradeExpr(val, filename, false, an)
}
var hilArithmeticOpSyms = map[hilast.ArithmeticOp]string{
hilast.ArithmeticOpAdd: " + ",
hilast.ArithmeticOpSub: " - ",
hilast.ArithmeticOpMul: " * ",
hilast.ArithmeticOpDiv: " / ",
hilast.ArithmeticOpMod: " % ",
hilast.ArithmeticOpLogicalAnd: " && ",
hilast.ArithmeticOpLogicalOr: " || ",
hilast.ArithmeticOpEqual: " == ",
hilast.ArithmeticOpNotEqual: " != ",
hilast.ArithmeticOpLessThan: " < ",
hilast.ArithmeticOpLessThanOrEqual: " <= ",
hilast.ArithmeticOpGreaterThan: " > ",
hilast.ArithmeticOpGreaterThanOrEqual: " >= ",
}
// upgradeTraversalParts might alter the given split parts from a HIL-style
// variable access to account for renamings made in Terraform v0.12.
func upgradeTraversalParts(parts []string, an *analysis) []string {
parts = upgradeCountTraversalParts(parts, an)
parts = upgradeTerraformRemoteStateTraversalParts(parts, an)
return parts
}
func upgradeCountTraversalParts(parts []string, an *analysis) []string {
// test_instance.foo.id needs to become test_instance.foo[0].id if
// count is set for test_instance.foo. Likewise, if count _isn't_ set
// then test_instance.foo.0.id must become test_instance.foo.id.
if len(parts) < 3 {
return parts
}
var addr addrs.Resource
var idxIdx int
switch parts[0] {
case "data":
addr.Mode = addrs.DataResourceMode
addr.Type = parts[1]
addr.Name = parts[2]
idxIdx = 3
default:
addr.Mode = addrs.ManagedResourceMode
addr.Type = parts[0]
addr.Name = parts[1]
idxIdx = 2
}
hasCount, exists := an.ResourceHasCount[addr]
if !exists {
// Probably not actually a resource instance at all, then.
return parts
}
// Since at least one attribute is required after a resource reference
// prior to Terraform v0.12, we can assume there will be at least enough
// parts to contain the index even if no index is actually present.
if idxIdx >= len(parts) {
return parts
}
maybeIdx := parts[idxIdx]
switch {
case hasCount:
if _, err := strconv.Atoi(maybeIdx); err == nil || maybeIdx == "*" {
// Has an index already, so no changes required.
return parts
}
// Need to insert index zero at idxIdx.
log.Printf("[TRACE] configupgrade: %s has count but reference does not have index, so adding one", addr)
newParts := make([]string, len(parts)+1)
copy(newParts, parts[:idxIdx])
newParts[idxIdx] = "0"
copy(newParts[idxIdx+1:], parts[idxIdx:])
return newParts
default:
// For removing indexes we'll be more conservative and only remove
// exactly index "0", because other indexes on a resource without
// count are invalid anyway and we're better off letting the normal
// configuration parser deal with that.
if maybeIdx != "0" {
return parts
}
// Need to remove the index zero.
log.Printf("[TRACE] configupgrade: %s does not have count but reference has index, so removing it", addr)
newParts := make([]string, len(parts)-1)
copy(newParts, parts[:idxIdx])
copy(newParts[idxIdx:], parts[idxIdx+1:])
return newParts
}
}
func upgradeTerraformRemoteStateTraversalParts(parts []string, an *analysis) []string {
// data.terraform_remote_state.x.foo needs to become
// data.terraform_remote_state.x.outputs.foo unless "foo" is a real
// attribute in the object type implied by the remote state schema.
if len(parts) < 4 {
return parts
}
if parts[0] != "data" || parts[1] != "terraform_remote_state" {
return parts
}
attrIdx := 3
if parts[attrIdx] == "*" {
attrIdx = 4 // data.terraform_remote_state.x.*.foo
} else if _, err := strconv.Atoi(parts[attrIdx]); err == nil {
attrIdx = 4 // data.terraform_remote_state.x.1.foo
}
if attrIdx >= len(parts) {
return parts
}
attrName := parts[attrIdx]
// Now we'll use the schema of data.terraform_remote_state to decide if
// the user intended this to be an output, or whether it's one of the real
// attributes of this data source.
var schema *configschema.Block
if providerSchema := an.ProviderSchemas["terraform"]; providerSchema != nil {
schema, _ = providerSchema.SchemaForResourceType(addrs.DataResourceMode, "terraform_remote_state")
}
// Schema should be available in all reasonable cases, but might be nil
// if input configuration contains a reference to a remote state data resource
// without actually defining that data resource. In that weird edge case,
// we'll just assume all attributes are outputs.
if schema != nil && schema.ImpliedType().HasAttribute(attrName) {
// User is accessing one of the real attributes, then, and we have
// no need to rewrite it.
return parts
}
// If we get down here then our task is to produce a new parts slice
// that has the fixed additional attribute name "outputs" inserted at
// attrIdx, retaining all other parts.
newParts := make([]string, len(parts)+1)
copy(newParts, parts[:attrIdx])
newParts[attrIdx] = "outputs"
copy(newParts[attrIdx+1:], parts[attrIdx:])
return newParts
}
func typeIsSettableFromTupleCons(ty cty.Type) bool {
return ty.IsListType() || ty.IsTupleType() || ty.IsSetType()
}