diff --git a/go.mod b/go.mod index 53252362..5cf45b2d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.14 require ( github.com/davecgh/go-spew v1.1.1 + github.com/hashicorp/go-version v1.2.0 github.com/hashicorp/terraform-json v0.5.0 golang.org/x/text v0.3.2 // indirect ) diff --git a/go.sum b/go.sum index 3295730d..21383c84 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/terraform-json v0.5.0 h1:7TV3/F3y7QVSuN4r9BEXqnWqrAyeOtON8f0wvREtyzs= github.com/hashicorp/terraform-json v0.5.0/go.mod h1:eAbqb4w0pSlRmdvl8fOyHAi/+8jnkVYN28gJkSJrLhU= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= diff --git a/tfexec/terraform.go b/tfexec/terraform.go index 9bfd4780..fc0208a3 100644 --- a/tfexec/terraform.go +++ b/tfexec/terraform.go @@ -10,15 +10,18 @@ import ( "os" "strings" + "github.com/hashicorp/go-version" tfjson "github.com/hashicorp/terraform-json" ) type Terraform struct { - execPath string - workingDir string - execVersion string - env []string - logger *log.Logger + execPath string + workingDir string + env []string + logger *log.Logger + + execVersion *version.Version + provVersions map[string]*version.Version } // NewTerraform returns a Terraform struct with default values for all fields. @@ -48,12 +51,13 @@ func NewTerraform(ctx context.Context, workingDir string, execPath string) (*Ter logger: log.New(ioutil.Discard, "", 0), } - execVersion, err := tf.version() + execVersion, provVersions, err := tf.version(ctx) if err != nil { return nil, &ErrNoSuitableBinary{err: fmt.Errorf("error running 'terraform version': %s", err)} } tf.execVersion = execVersion + tf.provVersions = provVersions return &tf, nil } @@ -78,22 +82,6 @@ func (tf *Terraform) SetLogger(logger *log.Logger) { tf.logger = logger } -func (tf *Terraform) version() (string, error) { - versionCmd := tf.buildTerraformCmd(context.Background(), "version") - var errBuf strings.Builder - var outBuf bytes.Buffer - versionCmd.Stderr = &errBuf - versionCmd.Stdout = &outBuf - - err := versionCmd.Run() - if err != nil { - fmt.Println(errBuf.String()) - return "", fmt.Errorf("%s, %s", err, errBuf.String()) - } - - return outBuf.String(), nil -} - type initConfig struct { backend bool backendConfig []string diff --git a/tfexec/version.go b/tfexec/version.go new file mode 100644 index 00000000..5d1c5a94 --- /dev/null +++ b/tfexec/version.go @@ -0,0 +1,71 @@ +package tfexec + +import ( + "bytes" + "context" + "fmt" + "regexp" + "strings" + + "github.com/hashicorp/go-version" +) + +func (tf *Terraform) version(ctx context.Context) (*version.Version, map[string]*version.Version, error) { + versionCmd := tf.buildTerraformCmd(ctx, "version") + var errBuf strings.Builder + var outBuf bytes.Buffer + versionCmd.Stderr = &errBuf + versionCmd.Stdout = &outBuf + + err := versionCmd.Run() + if err != nil { + fmt.Println(errBuf.String()) + return nil, nil, fmt.Errorf("unable to run version command: %w, %s", err, errBuf.String()) + } + + v, pv, err := parseVersionOutput(outBuf.String()) + if err != nil { + return nil, nil, fmt.Errorf("unable to parse version: %w", err) + } + + return v, pv, nil +} + +var ( + simpleVersionRe = `v?(?P[0-9]+(?:\.[0-9]+)*(?:-[A-Za-z0-9\.]+)?)` + + versionOutputRe = regexp.MustCompile(`^Terraform ` + simpleVersionRe) + providerVersionOutputRe = regexp.MustCompile(`(\n\+ provider[\. ](?P\S+) ` + simpleVersionRe + `)`) +) + +// TODO: maybe add JSON output to the version command to simplify all of this? +func parseVersionOutput(stdout string) (*version.Version, map[string]*version.Version, error) { + stdout = strings.TrimSpace(stdout) + + submatches := versionOutputRe.FindStringSubmatch(stdout) + if len(submatches) != 2 { + return nil, nil, fmt.Errorf("unexpected number of version matches %d for %s", len(submatches), stdout) + } + v, err := version.NewVersion(submatches[1]) + if err != nil { + return nil, nil, fmt.Errorf("unable to parse version %q: %w", submatches[1], err) + } + + allSubmatches := providerVersionOutputRe.FindAllStringSubmatch(stdout, -1) + provV := map[string]*version.Version{} + + for _, submatches := range allSubmatches { + if len(submatches) != 4 { + return nil, nil, fmt.Errorf("unexpected number of providerion version matches %d for %s", len(submatches), stdout) + } + + v, err := version.NewVersion(submatches[3]) + if err != nil { + return nil, nil, fmt.Errorf("unable to parse provider version %q: %w", submatches[3], err) + } + + provV[submatches[2]] = v + } + + return v, provV, err +} diff --git a/tfexec/version_test.go b/tfexec/version_test.go new file mode 100644 index 00000000..876a9708 --- /dev/null +++ b/tfexec/version_test.go @@ -0,0 +1,114 @@ +package tfexec + +import ( + "fmt" + "testing" + + "github.com/hashicorp/go-version" +) + +func TestParseVersionOutput(t *testing.T) { + var mustVer = func(s string) *version.Version { + v, err := version.NewVersion(s) + if err != nil { + t.Fatal(err) + } + return v + } + + for i, c := range []struct { + expectedV *version.Version + expectedProviders map[string]*version.Version + + stdout string + }{ + // 0.13 tests + { + mustVer("0.13.0-dev"), nil, ` +Terraform v0.13.0-dev`, + }, + { + mustVer("0.13.0-dev"), map[string]*version.Version{ + "registry.terraform.io/hashicorp/null": mustVer("2.1.2"), + "registry.terraform.io/paultyng/null": mustVer("0.1.0"), + }, ` +Terraform v0.13.0-dev ++ provider registry.terraform.io/hashicorp/null v2.1.2 ++ provider registry.terraform.io/paultyng/null v0.1.0`, + }, + { + mustVer("0.13.0-dev"), nil, ` +Terraform v0.13.0-dev + +Your version of Terraform is out of date! The latest version +is 0.13.1. You can update by downloading from https://www.terraform.io/downloads.html`, + }, + { + mustVer("0.13.0-dev"), map[string]*version.Version{ + "registry.terraform.io/hashicorp/null": mustVer("2.1.2"), + "registry.terraform.io/paultyng/null": mustVer("0.1.0"), + }, ` +Terraform v0.13.0-dev ++ provider registry.terraform.io/hashicorp/null v2.1.2 ++ provider registry.terraform.io/paultyng/null v0.1.0 + +Your version of Terraform is out of date! The latest version +is 0.13.1. You can update by downloading from https://www.terraform.io/downloads.html`, + }, + + // 0.12 tests + { + mustVer("0.12.26"), nil, ` +Terraform v0.12.26 +`, + }, + { + mustVer("0.12.26"), map[string]*version.Version{ + "null": mustVer("2.1.2"), + }, ` +Terraform v0.12.26 ++ provider.null v2.1.2 +`, + }, + { + mustVer("0.12.18"), nil, ` +Terraform v0.12.18 + +Your version of Terraform is out of date! The latest version +is 0.12.26. You can update by downloading from https://www.terraform.io/downloads.html +`, + }, + { + mustVer("0.12.18"), map[string]*version.Version{ + "null": mustVer("2.1.2"), + }, ` +Terraform v0.12.18 ++ provider.null v2.1.2 + +Your version of Terraform is out of date! The latest version +is 0.12.26. You can update by downloading from https://www.terraform.io/downloads.html +`, + }, + } { + t.Run(fmt.Sprintf("%d %s", i, c.expectedV), func(t *testing.T) { + actualV, actualProv, err := parseVersionOutput(c.stdout) + if err != nil { + t.Fatal(err) + } + + if !c.expectedV.Equal(actualV) { + t.Fatalf("expected %s, got %s", c.expectedV, actualV) + } + + for k, v := range c.expectedProviders { + if actual := actualProv[k]; actual == nil || !v.Equal(actual) { + t.Fatalf("expected %s for %s, got %s", v, k, actual) + } + } + + if len(c.expectedProviders) != len(actualProv) { + t.Fatalf("expected %d providers, got %d", len(c.expectedProviders), len(actualProv)) + } + }) + } +}