diff --git a/configs/configupgrade/test-fixtures/valid/interp-naked/input/try.tf b/configs/configupgrade/test-fixtures/valid/interp-naked/input/try.tf new file mode 100644 index 0000000000..fc8f9b6170 --- /dev/null +++ b/configs/configupgrade/test-fixtures/valid/interp-naked/input/try.tf @@ -0,0 +1,3 @@ +output "foo" { + value = "${path.module}" +} diff --git a/configs/configupgrade/test-fixtures/valid/interp-naked/want/try.tf b/configs/configupgrade/test-fixtures/valid/interp-naked/want/try.tf new file mode 100644 index 0000000000..83645b3a6b --- /dev/null +++ b/configs/configupgrade/test-fixtures/valid/interp-naked/want/try.tf @@ -0,0 +1,3 @@ +output "foo" { + value = path.module +} diff --git a/configs/configupgrade/test-fixtures/valid/interp-naked/want/versions.tf b/configs/configupgrade/test-fixtures/valid/interp-naked/want/versions.tf new file mode 100644 index 0000000000..d9b6f790b9 --- /dev/null +++ b/configs/configupgrade/test-fixtures/valid/interp-naked/want/versions.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 0.12" +} diff --git a/configs/configupgrade/test-fixtures/valid/noop-exprs/input/exprs.tf b/configs/configupgrade/test-fixtures/valid/noop-exprs/input/exprs.tf new file mode 100644 index 0000000000..27730ca3df --- /dev/null +++ b/configs/configupgrade/test-fixtures/valid/noop-exprs/input/exprs.tf @@ -0,0 +1,33 @@ +locals { + # Arithmetic + add = "${1 + 2}" + sub = "${1 - 2}" + mul = "${1 * 2}" + mod = "${4 % 2}" + and = "${true && true}" + or = "${true || true}" + equal = "${1 == 2}" + not_equal = "${1 != 2}" + less_than = "${1 < 2}" + greater_than = "${1 > 2}" + less_than_eq = "${1 <= 2}" + greater_than_eq = "${1 >= 2}" + neg = "${- local.add}" + + # Call + call_no_args = "${foo()}" + call_one_arg = "${foo(1)}" + call_two_args = "${foo(1, 2)}" + + # Conditional + cond = "${true ? 1 : 2}" + + # Index + index_str = "${foo["a"]}" + index_num = "${foo[1]}" + + # Variable Access + var_access_single = "${foo}" + var_access_dot = "${foo.bar}" + var_access_splat = "${foo.bar.*.baz}" +} diff --git a/configs/configupgrade/test-fixtures/valid/noop-exprs/want/exprs.tf b/configs/configupgrade/test-fixtures/valid/noop-exprs/want/exprs.tf new file mode 100644 index 0000000000..b9deae1027 --- /dev/null +++ b/configs/configupgrade/test-fixtures/valid/noop-exprs/want/exprs.tf @@ -0,0 +1,33 @@ +locals { + # Arithmetic + add = 1 + 2 + sub = 1 - 2 + mul = 1 * 2 + mod = 4 % 2 + and = true && true + or = true || true + equal = 1 == 2 + not_equal = 1 != 2 + less_than = 1 < 2 + greater_than = 1 > 2 + less_than_eq = 1 <= 2 + greater_than_eq = 1 >= 2 + neg = -local.add + + # Call + call_no_args = foo() + call_one_arg = foo(1) + call_two_args = foo(1, 2) + + # Conditional + cond = true ? 1 : 2 + + # Index + index_str = foo["a"] + index_num = foo[1] + + # Variable Access + var_access_single = foo + var_access_dot = foo.bar + var_access_splat = foo.bar.*.baz +} diff --git a/configs/configupgrade/test-fixtures/valid/noop-exprs/want/versions.tf b/configs/configupgrade/test-fixtures/valid/noop-exprs/want/versions.tf new file mode 100644 index 0000000000..d9b6f790b9 --- /dev/null +++ b/configs/configupgrade/test-fixtures/valid/noop-exprs/want/versions.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 0.12" +} diff --git a/configs/configupgrade/test-fixtures/valid/noop/input/variables.tf b/configs/configupgrade/test-fixtures/valid/noop/input/variables.tf index dcd9c11772..6f2da3b5cc 100644 --- a/configs/configupgrade/test-fixtures/valid/noop/input/variables.tf +++ b/configs/configupgrade/test-fixtures/valid/noop/input/variables.tf @@ -1,9 +1,15 @@ +/* This multi-line comment + should survive */ + # This comment should survive variable "foo" { default = 1 // This comment should also survive } +// These adjacent comments should remain adjacent +// to one another. + variable "bar" { /* This comment should survive too */ description = "bar the baz" diff --git a/configs/configupgrade/test-fixtures/valid/noop/want/variables.tf b/configs/configupgrade/test-fixtures/valid/noop/want/variables.tf index dcd9c11772..333bc11498 100644 --- a/configs/configupgrade/test-fixtures/valid/noop/want/variables.tf +++ b/configs/configupgrade/test-fixtures/valid/noop/want/variables.tf @@ -1,9 +1,14 @@ +/* This multi-line comment + should survive */ # This comment should survive variable "foo" { default = 1 // This comment should also survive } +// These adjacent comments should remain adjacent +// to one another. + variable "bar" { /* This comment should survive too */ description = "bar the baz" diff --git a/configs/configupgrade/test-fixtures/valid/noop/want/versions.tf b/configs/configupgrade/test-fixtures/valid/noop/want/versions.tf index e69de29bb2..d9b6f790b9 100644 --- a/configs/configupgrade/test-fixtures/valid/noop/want/versions.tf +++ b/configs/configupgrade/test-fixtures/valid/noop/want/versions.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 0.12" +} diff --git a/configs/configupgrade/test-fixtures/valid/rename-json-conflict/want/versions.tf b/configs/configupgrade/test-fixtures/valid/rename-json-conflict/want/versions.tf index e69de29bb2..d9b6f790b9 100644 --- a/configs/configupgrade/test-fixtures/valid/rename-json-conflict/want/versions.tf +++ b/configs/configupgrade/test-fixtures/valid/rename-json-conflict/want/versions.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 0.12" +} diff --git a/configs/configupgrade/test-fixtures/valid/rename-json/want/versions.tf b/configs/configupgrade/test-fixtures/valid/rename-json/want/versions.tf index e69de29bb2..d9b6f790b9 100644 --- a/configs/configupgrade/test-fixtures/valid/rename-json/want/versions.tf +++ b/configs/configupgrade/test-fixtures/valid/rename-json/want/versions.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 0.12" +} diff --git a/configs/configupgrade/test-fixtures/valid/variable-type/input/variables.tf b/configs/configupgrade/test-fixtures/valid/variable-type/input/variables.tf new file mode 100644 index 0000000000..03ab6b1bda --- /dev/null +++ b/configs/configupgrade/test-fixtures/valid/variable-type/input/variables.tf @@ -0,0 +1,11 @@ +variable "s" { + type = "string" +} + +variable "l" { + type = "list" +} + +variable "m" { + type = "map" +} diff --git a/configs/configupgrade/test-fixtures/valid/variable-type/want/variables.tf b/configs/configupgrade/test-fixtures/valid/variable-type/want/variables.tf new file mode 100644 index 0000000000..317e92dc9e --- /dev/null +++ b/configs/configupgrade/test-fixtures/valid/variable-type/want/variables.tf @@ -0,0 +1,11 @@ +variable "s" { + type = string +} + +variable "l" { + type = list(string) +} + +variable "m" { + type = map(string) +} diff --git a/configs/configupgrade/test-fixtures/valid/variable-type/want/versions.tf b/configs/configupgrade/test-fixtures/valid/variable-type/want/versions.tf new file mode 100644 index 0000000000..d9b6f790b9 --- /dev/null +++ b/configs/configupgrade/test-fixtures/valid/variable-type/want/versions.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 0.12" +} diff --git a/configs/configupgrade/upgrade.go b/configs/configupgrade/upgrade.go index 3e0c3d0df2..0707a8d4e7 100644 --- a/configs/configupgrade/upgrade.go +++ b/configs/configupgrade/upgrade.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform/tfdiags" hcl2 "github.com/hashicorp/hcl2/hcl" + hcl2write "github.com/hashicorp/hcl2/hclwrite" ) // Upgrade takes some input module sources and produces a new ModuleSources @@ -89,16 +90,25 @@ func Upgrade(input ModuleSources) (ModuleSources, tfdiags.Diagnostics) { } // TODO: Actually rewrite this .tf file. - ret[name] = src + result, fileDiags := upgradeNativeSyntaxFile(name, src) + diags = diags.Append(fileDiags) + if fileDiags.HasErrors() { + // Leave unchanged, then. + ret[name] = src + continue + } + + ret[name] = hcl2write.Format(result.Content) } - // Generate our versions.tf file that both records the fact that we now - // require Terraform Core 0.12 and gathers all of the provider version - // requirements that might previously have been scattered in various - // "provider" blocks elsewhere. versionsName := ret.UnusedFilename("versions.tf") - // TODO: Actually populate this. - ret[versionsName] = make([]byte, 0) + ret[versionsName] = []byte(newVersionConstraint) return ret, diags } + +const newVersionConstraint = ` +terraform { + required_version = ">= 0.12" +} +` diff --git a/configs/configupgrade/upgrade_expr.go b/configs/configupgrade/upgrade_expr.go new file mode 100644 index 0000000000..e6d1ab6b70 --- /dev/null +++ b/configs/configupgrade/upgrade_expr.go @@ -0,0 +1,234 @@ +package configupgrade + +import ( + "bytes" + "fmt" + "strconv" + + hcl2 "github.com/hashicorp/hcl2/hcl" + + 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/tfdiags" +) + +func upgradeExpr(val interface{}, filename string, interp bool) ([]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. + switch tv := val.(type) { + + case *hcl1ast.LiteralType: + litVal := tv.Token.Value() + switch tv.Token.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) + 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.Token.Text) + + } + + 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.) + 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: + buf.WriteString(tv.Name) + + 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) + diags = diags.Append(exprDiags) + rhsSrc, exprDiags := upgradeExpr(rhsExpr, filename, true) + 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 + + buf.WriteString(name) + buf.WriteByte('(') + for i, arg := range args { + if i > 0 { + buf.WriteString(", ") + } + + exprSrc, exprDiags := upgradeExpr(arg, filename, true) + diags = diags.Append(exprDiags) + buf.Write(exprSrc) + } + buf.WriteByte(')') + + case *hilast.Conditional: + condSrc, exprDiags := upgradeExpr(tv.CondExpr, filename, true) + diags = diags.Append(exprDiags) + trueSrc, exprDiags := upgradeExpr(tv.TrueExpr, filename, true) + diags = diags.Append(exprDiags) + falseSrc, exprDiags := upgradeExpr(tv.FalseExpr, filename, true) + 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) + diags = diags.Append(exprDiags) + keySrc, exprDiags := upgradeExpr(tv.Key, filename, true) + 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) + 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) + 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 +} + +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: " >= ", +} diff --git a/configs/configupgrade/upgrade_native.go b/configs/configupgrade/upgrade_native.go new file mode 100644 index 0000000000..9747719d2a --- /dev/null +++ b/configs/configupgrade/upgrade_native.go @@ -0,0 +1,436 @@ +package configupgrade + +import ( + "bytes" + "fmt" + "sort" + "strings" + + version "github.com/hashicorp/go-version" + + hcl1ast "github.com/hashicorp/hcl/hcl/ast" + hcl1parser "github.com/hashicorp/hcl/hcl/parser" + 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" + + "github.com/hashicorp/terraform/tfdiags" +) + +type upgradeFileResult struct { + Content []byte + ProviderRequirements map[string]version.Constraints +} + +func upgradeNativeSyntaxFile(filename string, src []byte) (upgradeFileResult, tfdiags.Diagnostics) { + var result upgradeFileResult + var diags tfdiags.Diagnostics + + var buf bytes.Buffer + + f, err := hcl1parser.Parse(src) + if err != nil { + return result, diags.Append(&hcl2.Diagnostic{ + Severity: hcl2.DiagError, + Summary: "Syntax error in configuration file", + Detail: fmt.Sprintf("Error while parsing: %s", err), + Subject: hcl1ErrSubjectRange(filename, err), + }) + } + + rootList := f.Node.(*hcl1ast.ObjectList) + rootItems := rootList.Items + adhocComments := collectAdhocComments(f) + + for _, item := range rootItems { + comments := adhocComments.TakeBefore(item) + for _, group := range comments { + printComments(&buf, group) + buf.WriteByte('\n') // Extra separator after each group + } + + 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) + } + body, isObject := item.Val.(*hcl1ast.ObjectType) + if !isObject { + // Should never happen for valid input, since we don't expect + // any non-block items at our top level. + diags = diags.Append(&hcl2.Diagnostic{ + Severity: hcl2.DiagWarning, + Summary: "Unsupported top-level attribute", + Detail: fmt.Sprintf("Attribute %q is not expected here, so its expression was not migrated.", blockType), + Subject: hcl1PosRange(filename, item.Keys[0].Pos()).Ptr(), + }) + // Preserve the item as-is, using the hcl1printer package. + buf.WriteString("# TF-UPGRADE-TODO: Top-level attributes are not valid, so this was not automatically migrated.\n") + hcl1printer.Fprint(&buf, item) + buf.WriteString("\n\n") + continue + } + + switch blockType { + + case "variable": + printComments(&buf, item.LeadComment) + printBlockOpen(&buf, blockType, labels, item.LineComment) + args := body.List.Items + for i, arg := range args { + if len(arg.Keys) != 1 { + // Should never happen for valid input, since there are no nested blocks expected here. + diags = diags.Append(&hcl2.Diagnostic{ + Severity: hcl2.DiagWarning, + Summary: "Invalid nested block", + Detail: fmt.Sprintf("Blocks of type %q are not expected here, so this was not automatically migrated.", arg.Keys[0].Token.Value().(string)), + Subject: hcl1PosRange(filename, arg.Keys[0].Pos()).Ptr(), + }) + // Preserve the item as-is, using the hcl1printer package. + buf.WriteString("\n# TF-UPGRADE-TODO: Blocks are not expected here, so this was not automatically migrated.\n") + hcl1printer.Fprint(&buf, arg) + buf.WriteString("\n\n") + continue + } + + comments := adhocComments.TakeBefore(arg) + for _, group := range comments { + printComments(&buf, group) + buf.WriteByte('\n') // Extra separator after each group + } + + printComments(&buf, arg.LeadComment) + + switch arg.Keys[0].Token.Value() { + case "type": + // It is no longer idiomatic to place the type keyword in quotes, + // so we'll unquote it here as long as it looks like the result + // will be valid. + if lit, isLit := arg.Val.(*hcl1ast.LiteralType); isLit { + if lit.Token.Type == hcl1token.STRING { + kw := lit.Token.Value().(string) + if hcl2syntax.ValidIdentifier(kw) { + + // "list" and "map" in older versions really meant + // list and map of strings, so we'll migrate to + // that and let the user adjust to "any" as + // the element type if desired. + switch strings.TrimSpace(kw) { + case "list": + kw = "list(string)" + case "map": + kw = "map(string)" + } + + printAttribute(&buf, "type", []byte(kw), arg.LineComment) + break + } + } + } + // If we got something invalid there then we'll just fall through + // into the default case and migrate it as a normal expression. + fallthrough + default: + valSrc, valDiags := upgradeExpr(arg.Val, filename, false) + diags = diags.Append(valDiags) + printAttribute(&buf, arg.Keys[0].Token.Value().(string), valSrc, arg.LineComment) + } + + // If we have another item and it's more than one line away + // from the current one then we'll print an extra blank line + // to retain that separation. + if (i + 1) < len(args) { + next := args[i+1] + thisPos := arg.Pos() + nextPos := next.Pos() + if nextPos.Line-thisPos.Line > 1 { + buf.WriteByte('\n') + } + } + } + buf.WriteString("}\n\n") + + case "output": + printComments(&buf, item.LeadComment) + printBlockOpen(&buf, blockType, labels, item.LineComment) + args := body.List.Items + for i, arg := range args { + if len(arg.Keys) != 1 { + // Should never happen for valid input, since there are no nested blocks expected here. + diags = diags.Append(&hcl2.Diagnostic{ + Severity: hcl2.DiagWarning, + Summary: "Invalid nested block", + Detail: fmt.Sprintf("Blocks of type %q are not expected here, so this was not automatically migrated.", arg.Keys[0].Token.Value().(string)), + Subject: hcl1PosRange(filename, arg.Keys[0].Pos()).Ptr(), + }) + // Preserve the item as-is, using the hcl1printer package. + buf.WriteString("\n# TF-UPGRADE-TODO: Blocks are not expected here, so this was not automatically migrated.\n") + hcl1printer.Fprint(&buf, arg) + buf.WriteString("\n\n") + continue + } + + comments := adhocComments.TakeBefore(arg) + for _, group := range comments { + printComments(&buf, group) + buf.WriteByte('\n') // Extra separator after each group + } + + printComments(&buf, arg.LeadComment) + + interp := false + switch arg.Keys[0].Token.Value() { + case "value": + interp = true + } + + valSrc, valDiags := upgradeExpr(arg.Val, filename, interp) + diags = diags.Append(valDiags) + printAttribute(&buf, arg.Keys[0].Token.Value().(string), valSrc, arg.LineComment) + + // If we have another item and it's more than one line away + // from the current one then we'll print an extra blank line + // to retain that separation. + if (i + 1) < len(args) { + next := args[i+1] + thisPos := arg.Pos() + nextPos := next.Pos() + if nextPos.Line-thisPos.Line > 1 { + buf.WriteByte('\n') + } + } + } + buf.WriteString("}\n\n") + + case "locals": + printComments(&buf, item.LeadComment) + printBlockOpen(&buf, blockType, labels, item.LineComment) + + args := body.List.Items + for i, arg := range args { + if len(arg.Keys) != 1 { + // Should never happen for valid input, since there are no nested blocks expected here. + diags = diags.Append(&hcl2.Diagnostic{ + Severity: hcl2.DiagWarning, + Summary: "Invalid nested block", + Detail: fmt.Sprintf("Blocks of type %q are not expected here, so this was not automatically migrated.", arg.Keys[0].Token.Value().(string)), + Subject: hcl1PosRange(filename, arg.Keys[0].Pos()).Ptr(), + }) + // Preserve the item as-is, using the hcl1printer package. + buf.WriteString("\n# TF-UPGRADE-TODO: Blocks are not expected here, so this was not automatically migrated.\n") + hcl1printer.Fprint(&buf, arg) + buf.WriteString("\n\n") + continue + } + + comments := adhocComments.TakeBefore(arg) + for _, group := range comments { + printComments(&buf, group) + buf.WriteByte('\n') // Extra separator after each group + } + + printComments(&buf, arg.LeadComment) + + name := arg.Keys[0].Token.Value().(string) + expr := arg.Val + exprSrc, exprDiags := upgradeExpr(expr, filename, true) + diags = diags.Append(exprDiags) + printAttribute(&buf, name, exprSrc, arg.LineComment) + + // If we have another item and it's more than one line away + // from the current one then we'll print an extra blank line + // to retain that separation. + if (i + 1) < len(args) { + next := args[i+1] + thisPos := arg.Pos() + nextPos := next.Pos() + if nextPos.Line-thisPos.Line > 1 { + buf.WriteByte('\n') + } + } + } + buf.WriteString("}\n\n") + + default: + // Should never happen for valid input, because the above cases + // are exhaustive for valid blocks as of Terraform 0.11. + diags = diags.Append(&hcl2.Diagnostic{ + Severity: hcl2.DiagWarning, + Summary: "Unsupported root block type", + Detail: fmt.Sprintf("The block type %q is not expected here, so its content was not migrated.", blockType), + Subject: hcl1PosRange(filename, item.Keys[0].Pos()).Ptr(), + }) + + // Preserve the block as-is, using the hcl1printer package. + buf.WriteString("# TF-UPGRADE-TODO: Block type was not recognized, so this block and its contents were not automatically migrated.\n") + hcl1printer.Fprint(&buf, item) + buf.WriteString("\n\n") + continue + } + } + + // Print out any leftover comments + for _, group := range *adhocComments { + printComments(&buf, group) + } + + result.Content = buf.Bytes() + + return result, diags +} + +func printComments(buf *bytes.Buffer, group *hcl1ast.CommentGroup) { + if group == nil { + return + } + for _, comment := range group.List { + buf.WriteString(comment.Text) + buf.WriteByte('\n') + } +} + +func printBlockOpen(buf *bytes.Buffer, blockType string, labels []string, commentGroup *hcl1ast.CommentGroup) { + buf.WriteString(blockType) + for _, label := range labels { + buf.WriteByte(' ') + printQuotedString(buf, label) + } + buf.WriteString(" {") + if commentGroup != nil { + for _, c := range commentGroup.List { + buf.WriteByte(' ') + buf.WriteString(c.Text) + } + } + buf.WriteByte('\n') +} + +func printAttribute(buf *bytes.Buffer, name string, valSrc []byte, commentGroup *hcl1ast.CommentGroup) { + buf.WriteString(name) + buf.WriteString(" = ") + buf.Write(valSrc) + if commentGroup != nil { + for _, c := range commentGroup.List { + buf.WriteByte(' ') + buf.WriteString(c.Text) + } + } + buf.WriteByte('\n') +} + +func printQuotedString(buf *bytes.Buffer, val string) { + buf.WriteByte('"') + printStringLiteralFromHILOutput(buf, val) + buf.WriteByte('"') +} + +func printStringLiteralFromHILOutput(buf *bytes.Buffer, val string) { + val = strings.Replace(val, `\`, `\\`, -1) + val = strings.Replace(val, `"`, `\"`, -1) + val = strings.Replace(val, "\n", `\n`, -1) + val = strings.Replace(val, "\r", `\r`, -1) + val = strings.Replace(val, `${`, `$${`, -1) + val = strings.Replace(val, `%{`, `%%{`, -1) + buf.WriteString(val) +} + +func collectAdhocComments(f *hcl1ast.File) *commentQueue { + comments := make(map[hcl1token.Pos]*hcl1ast.CommentGroup) + for _, c := range f.Comments { + comments[c.Pos()] = c + } + + // We'll remove from our map any comments that are attached to specific + // nodes as lead or line comments, since we'll find those during our + // walk anyway. + hcl1ast.Walk(f, func(nn hcl1ast.Node) (hcl1ast.Node, bool) { + switch t := nn.(type) { + case *hcl1ast.LiteralType: + if t.LeadComment != nil { + for _, comment := range t.LeadComment.List { + delete(comments, comment.Pos()) + } + } + + if t.LineComment != nil { + for _, comment := range t.LineComment.List { + delete(comments, comment.Pos()) + } + } + case *hcl1ast.ObjectItem: + if t.LeadComment != nil { + for _, comment := range t.LeadComment.List { + delete(comments, comment.Pos()) + } + } + + if t.LineComment != nil { + for _, comment := range t.LineComment.List { + delete(comments, comment.Pos()) + } + } + } + + return nn, true + }) + + if len(comments) == 0 { + var ret commentQueue + return &ret + } + + ret := make([]*hcl1ast.CommentGroup, 0, len(comments)) + for _, c := range comments { + ret = append(ret, c) + } + sort.Slice(ret, func(i, j int) bool { + return ret[i].Pos().Before(ret[j].Pos()) + }) + queue := commentQueue(ret) + return &queue +} + +type commentQueue []*hcl1ast.CommentGroup + +func (q *commentQueue) TakeBefore(node hcl1ast.Node) []*hcl1ast.CommentGroup { + toPos := node.Pos() + var i int + for i = 0; i < len(*q); i++ { + if (*q)[i].Pos().After(toPos) { + break + } + } + if i == 0 { + return nil + } + + ret := (*q)[:i] + *q = (*q)[i:] + + return ret +} + +func hcl1ErrSubjectRange(filename string, err error) *hcl2.Range { + if pe, isPos := err.(*hcl1parser.PosError); isPos { + return hcl1PosRange(filename, pe.Pos) + } + return nil +} + +func hcl1PosRange(filename string, pos hcl1token.Pos) *hcl2.Range { + return &hcl2.Range{ + Filename: filename, + Start: hcl2.Pos{ + Line: pos.Line, + Column: pos.Column, + Byte: pos.Offset, + }, + End: hcl2.Pos{ + Line: pos.Line, + Column: pos.Column, + Byte: pos.Offset, + }, + } +} diff --git a/configs/configupgrade/upgrade_test.go b/configs/configupgrade/upgrade_test.go index 0f03d3c59e..b3ffd344ad 100644 --- a/configs/configupgrade/upgrade_test.go +++ b/configs/configupgrade/upgrade_test.go @@ -2,7 +2,10 @@ package configupgrade import ( "bytes" + "io" "io/ioutil" + "os" + "os/exec" "path/filepath" "testing" ) @@ -56,8 +59,11 @@ func TestUpgradeValid(t *testing.T) { continue } + got = bytes.TrimSpace(got) + want = bytes.TrimSpace(want) if !bytes.Equal(got, want) { - t.Errorf("wrong content in %q\n=== GOT ===\n%s\n=== WANT ===\n%s", name, got, want) + diff := diffSourceFiles(got, want) + t.Errorf("wrong content in %q\n%s", name, diff) } } @@ -96,3 +102,67 @@ func TestUpgradeRenameJSON(t *testing.T) { t.Errorf("misnamed-json.tf.json was not created") } } + +func diffSourceFiles(got, want []byte) []byte { + // We'll try to run "diff -u" here to get nice output, but if that fails + // (e.g. because we're running on a machine without diff installed) then + // we'll fall back on just printing out the before and after in full. + gotR, gotW, err := os.Pipe() + if err != nil { + return diffSourceFilesFallback(got, want) + } + defer gotR.Close() + defer gotW.Close() + wantR, wantW, err := os.Pipe() + if err != nil { + return diffSourceFilesFallback(got, want) + } + defer wantR.Close() + defer wantW.Close() + + cmd := exec.Command("diff", "-u", "--label=GOT", "--label=WANT", "/dev/fd/3", "/dev/fd/4") + cmd.ExtraFiles = []*os.File{gotR, wantR} + stdout, err := cmd.StdoutPipe() + stderr, err := cmd.StderrPipe() + if err != nil { + return diffSourceFilesFallback(got, want) + } + + go func() { + wantW.Write(want) + wantW.Close() + }() + go func() { + gotW.Write(got) + gotW.Close() + }() + + err = cmd.Start() + if err != nil { + return diffSourceFilesFallback(got, want) + } + + outR := io.MultiReader(stdout, stderr) + out, err := ioutil.ReadAll(outR) + if err != nil { + return diffSourceFilesFallback(got, want) + } + + cmd.Wait() // not checking errors here because on failure we'll have stderr captured to return + + const noNewline = "\\ No newline at end of file\n" + if bytes.HasSuffix(out, []byte(noNewline)) { + out = out[:len(out)-len(noNewline)] + } + return out +} + +func diffSourceFilesFallback(got, want []byte) []byte { + var buf bytes.Buffer + buf.WriteString("=== GOT ===\n") + buf.Write(got) + buf.WriteString("\n=== WANT ===\n") + buf.Write(want) + buf.WriteString("\n") + return buf.Bytes() +}