217 lines
6.3 KiB
Go
217 lines
6.3 KiB
Go
|
package configupgrade
|
||
|
|
||
|
import (
|
||
|
"fmt"
|
||
|
"io/ioutil"
|
||
|
"path/filepath"
|
||
|
"strings"
|
||
|
|
||
|
"github.com/zclconf/go-cty/cty"
|
||
|
|
||
|
"github.com/hashicorp/terraform/configs"
|
||
|
"github.com/hashicorp/terraform/tfdiags"
|
||
|
|
||
|
"github.com/hashicorp/hcl2/hcl"
|
||
|
hcl2syntax "github.com/hashicorp/hcl2/hcl/hclsyntax"
|
||
|
|
||
|
version "github.com/hashicorp/go-version"
|
||
|
)
|
||
|
|
||
|
type ModuleSources map[string][]byte
|
||
|
|
||
|
// LoadModule looks for Terraform configuration files in the given directory
|
||
|
// and loads each of them into memory as source code, in preparation for
|
||
|
// further analysis and conversion.
|
||
|
//
|
||
|
// At this stage the files are not parsed at all. Instead, we just read the
|
||
|
// raw bytes from the file so that they can be passed into a parser in a
|
||
|
// separate step.
|
||
|
//
|
||
|
// If the given directory or any of the files cannot be read, an error is
|
||
|
// returned. It is not safe to proceed with processing in that case because
|
||
|
// we cannot "see" all of the source code for the configuration.
|
||
|
func LoadModule(dir string) (ModuleSources, error) {
|
||
|
entries, err := ioutil.ReadDir(dir)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
ret := make(ModuleSources)
|
||
|
for _, entry := range entries {
|
||
|
name := entry.Name()
|
||
|
if entry.IsDir() {
|
||
|
continue
|
||
|
}
|
||
|
if configs.IsIgnoredFile(name) {
|
||
|
continue
|
||
|
}
|
||
|
ext := fileExt(name)
|
||
|
if ext == "" {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
fullPath := filepath.Join(dir, name)
|
||
|
src, err := ioutil.ReadFile(fullPath)
|
||
|
if err != nil {
|
||
|
return nil, err
|
||
|
}
|
||
|
|
||
|
ret[name] = src
|
||
|
}
|
||
|
|
||
|
return ret, nil
|
||
|
}
|
||
|
|
||
|
// UnusedFilename finds a filename that isn't already used by a file in
|
||
|
// the receiving sources and returns it.
|
||
|
//
|
||
|
// The given "proposed" name is returned verbatim if it isn't already used.
|
||
|
// Otherwise, the function will try appending incrementing integers to the
|
||
|
// proposed name until an unused name is found. Callers should propose names
|
||
|
// that they do not expect to already be in use so that numeric suffixes are
|
||
|
// only used in rare cases.
|
||
|
//
|
||
|
// The proposed name must end in either ".tf" or ".tf.json" because a
|
||
|
// ModuleSources only has visibility into such files. This function will
|
||
|
// panic if given a file whose name does not end with one of these
|
||
|
// extensions.
|
||
|
//
|
||
|
// A ModuleSources only works on one directory at a time, so the proposed
|
||
|
// name must not contain any directory separator characters.
|
||
|
func (ms ModuleSources) UnusedFilename(proposed string) string {
|
||
|
ext := fileExt(proposed)
|
||
|
if ext == "" {
|
||
|
panic(fmt.Errorf("method UnusedFilename used with invalid proposal %q", proposed))
|
||
|
}
|
||
|
|
||
|
if _, exists := ms[proposed]; !exists {
|
||
|
return proposed
|
||
|
}
|
||
|
|
||
|
base := proposed[:len(proposed)-len(ext)]
|
||
|
for i := 1; ; i++ {
|
||
|
try := fmt.Sprintf("%s-%d%s", base, i, ext)
|
||
|
if _, exists := ms[try]; !exists {
|
||
|
return try
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// MaybeAlreadyUpgraded is a heuristic to see if a given module may have
|
||
|
// already been upgraded by this package.
|
||
|
//
|
||
|
// The heuristic used is to look for a Terraform Core version constraint in
|
||
|
// any of the given sources that seems to be requiring a version greater than
|
||
|
// or equal to v0.12.0. If true is returned then the source range of the found
|
||
|
// version constraint is returned in case the caller wishes to present it to
|
||
|
// the user as context for a warning message. The returned range is not
|
||
|
// meaningful if false is returned.
|
||
|
func (ms ModuleSources) MaybeAlreadyUpgraded() (bool, tfdiags.SourceRange) {
|
||
|
for name, src := range ms {
|
||
|
f, diags := hcl2syntax.ParseConfig(src, name, hcl.Pos{Line: 1, Column: 1})
|
||
|
if diags.HasErrors() {
|
||
|
// If we can't parse at all then that's a reasonable signal that
|
||
|
// we _haven't_ been upgraded yet, but we'll continue checking
|
||
|
// other files anyway.
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
content, _, diags := f.Body.PartialContent(&hcl.BodySchema{
|
||
|
Blocks: []hcl.BlockHeaderSchema{
|
||
|
{
|
||
|
Type: "terraform",
|
||
|
},
|
||
|
},
|
||
|
})
|
||
|
if diags.HasErrors() {
|
||
|
// Suggests that the file has an invalid "terraform" block, such
|
||
|
// as one with labels.
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
for _, block := range content.Blocks {
|
||
|
content, _, diags := block.Body.PartialContent(&hcl.BodySchema{
|
||
|
Attributes: []hcl.AttributeSchema{
|
||
|
{
|
||
|
Name: "required_version",
|
||
|
},
|
||
|
},
|
||
|
})
|
||
|
if diags.HasErrors() {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
attr, present := content.Attributes["required_version"]
|
||
|
if !present {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
constraintVal, diags := attr.Expr.Value(nil)
|
||
|
if diags.HasErrors() {
|
||
|
continue
|
||
|
}
|
||
|
if constraintVal.Type() != cty.String || constraintVal.IsNull() {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
constraints, err := version.NewConstraint(constraintVal.AsString())
|
||
|
if err != nil {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// The go-version package doesn't actually let us see the details
|
||
|
// of the parsed constraints here, so we now need a bit of an
|
||
|
// abstraction inversion to decide if any of the given constraints
|
||
|
// match our heuristic. However, we do at least get to benefit
|
||
|
// from go-version's ability to extract multiple constraints from
|
||
|
// a single string and the fact that it's already validated each
|
||
|
// constraint to match its expected pattern.
|
||
|
Constraints:
|
||
|
for _, constraint := range constraints {
|
||
|
str := strings.TrimSpace(constraint.String())
|
||
|
// Want to match >, >= and ~> here.
|
||
|
if !(strings.HasPrefix(str, ">") || strings.HasPrefix(str, "~>")) {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
// Try to find something in this string that'll parse as a version.
|
||
|
for i := 1; i < len(str); i++ {
|
||
|
candidate := str[i:]
|
||
|
v, err := version.NewVersion(candidate)
|
||
|
if err != nil {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
if v.Equal(firstVersionWithNewParser) || v.GreaterThan(firstVersionWithNewParser) {
|
||
|
// This constraint appears to be preventing the old
|
||
|
// parser from being used, so we suspect it was
|
||
|
// already upgraded.
|
||
|
return true, tfdiags.SourceRangeFromHCL(attr.Range)
|
||
|
}
|
||
|
|
||
|
// If we fall out here then we _did_ find something that
|
||
|
// parses as a version, so we'll stop and move on to the
|
||
|
// next constraint. (Otherwise we'll pass by 0.7.0 and find
|
||
|
// 7.0, which is also a valid version.)
|
||
|
continue Constraints
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
return false, tfdiags.SourceRange{}
|
||
|
}
|
||
|
|
||
|
var firstVersionWithNewParser = version.Must(version.NewVersion("0.12.0"))
|
||
|
|
||
|
// fileExt returns the Terraform configuration extension of the given
|
||
|
// path, or a blank string if it is not a recognized extension.
|
||
|
func fileExt(path string) string {
|
||
|
if strings.HasSuffix(path, ".tf") {
|
||
|
return ".tf"
|
||
|
} else if strings.HasSuffix(path, ".tf.json") {
|
||
|
return ".tf.json"
|
||
|
} else {
|
||
|
return ""
|
||
|
}
|
||
|
}
|