Skip to content

Commit

Permalink
Add version output parsing and runtime tests
Browse files Browse the repository at this point in the history
  • Loading branch information
paultyng committed Jul 13, 2020
1 parent c11057b commit 4898022
Show file tree
Hide file tree
Showing 5 changed files with 271 additions and 34 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
github.com/davecgh/go-spew v1.1.1
github.com/hashicorp/go-checkpoint v0.5.0
github.com/hashicorp/go-getter v1.4.0
github.com/hashicorp/go-version v1.1.0
github.com/hashicorp/go-version v1.2.1
github.com/hashicorp/terraform-json v0.5.0
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5
)
3 changes: 2 additions & 1 deletion go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhE
github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I=
github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.1.0 h1:bPIoEKD27tNdebFGGxxYwcL4nepeY4j1QP23PFRGzg0=
github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI=
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
Expand Down
43 changes: 11 additions & 32 deletions tfexec/terraform.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,30 @@
package tfexec

import (
"bytes"
"context"
"fmt"
"io/ioutil"
"log"
"os"
"strings"
"sync"

"github.com/hashicorp/go-version"
)

type Terraform struct {
execPath string
workingDir string
execVersion string
env []string
logger *log.Logger
execPath string
workingDir string
env []string
logger *log.Logger

versionLock sync.Mutex
execVersion *version.Version
provVersions map[string]*version.Version
}

// NewTerraform returns a Terraform struct with default values for all fields.
// If a blank execPath is supplied, NewTerraform will attempt to locate an
// appropriate binary on the system PATH.
func NewTerraform(workingDir string, execPath string) (*Terraform, error) {
var err error
if workingDir == "" {
return nil, fmt.Errorf("Terraform cannot be initialised with empty workdir")
}
Expand All @@ -43,13 +45,6 @@ func NewTerraform(workingDir string, execPath string) (*Terraform, error) {
logger: log.New(ioutil.Discard, "", 0),
}

execVersion, err := tf.version()
if err != nil {
return nil, &ErrNoSuitableBinary{err: fmt.Errorf("error running 'terraform version': %s", err)}
}

tf.execVersion = execVersion

return &tf, nil
}

Expand All @@ -72,19 +67,3 @@ func (tf *Terraform) SetEnv(env map[string]string) {
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
}
91 changes: 91 additions & 0 deletions tfexec/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package tfexec

import (
"bytes"
"context"
"fmt"
"regexp"
"strings"

"github.com/hashicorp/go-version"
)

// Version returns structured output from the terraform version command including both the Terraform CLI version
// and any initialized provider versions. This will read cached values when present unless the skipCache parameter
// is set to true.
func (tf *Terraform) Version(ctx context.Context, skipCache bool) (tfVersion *version.Version, providerVersions map[string]*version.Version, err error) {
tf.versionLock.Lock()
defer tf.versionLock.Unlock()

if tf.execVersion == nil || skipCache {
tf.execVersion, tf.provVersions, err = tf.version(ctx)
if err != nil {
return nil, nil, err
}
}

return tf.execVersion, tf.provVersions, nil
}

// version does not use the locking on the Terraform instance and should probably not be used directly, prefer Version.
func (tf *Terraform) version(ctx context.Context) (*version.Version, map[string]*version.Version, error) {
// TODO: 0.13.0-beta2? and above supports a `-json` on the version command, should add support
// for that here and fallback to string parsing

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())
}

tfVersion, providerVersions, err := parseVersionOutput(outBuf.String())
if err != nil {
return nil, nil, fmt.Errorf("unable to parse version: %w", err)
}

return tfVersion, providerVersions, nil
}

var (
simpleVersionRe = `v?(?P<version>[0-9]+(?:\.[0-9]+)*(?:-[A-Za-z0-9\.]+)?)`

versionOutputRe = regexp.MustCompile(`^Terraform ` + simpleVersionRe)
providerVersionOutputRe = regexp.MustCompile(`(\n\+ provider[\. ](?P<name>\S+) ` + simpleVersionRe + `)`)
)

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
}
166 changes: 166 additions & 0 deletions tfexec/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package tfexec

import (
"context"
"fmt"
"os"
"path/filepath"
"testing"

"github.com/hashicorp/go-version"
)

func TestVersion(t *testing.T) {
ctx := context.Background()

for _, tfv := range []string{
"0.11.14",
"0.12.28",
"0.13.0-beta3",
} {
t.Run(tfv, func(t *testing.T) {
td := testTempDir(t)
defer os.RemoveAll(td)

err := copyFile(filepath.Join(testFixtureDir, "basic/main.tf"), td)
if err != nil {
t.Fatal(err)
}

tf, err := NewTerraform(td, tfVersion(t, tfv))
if err != nil {
t.Fatal(err)
}

err = tf.Init(ctx, Lock(false))
if err != nil {
t.Fatal(err)
}

v, _, err := tf.Version(ctx, false)
if err != nil {
t.Fatal(err)
}
if v.String() != tfv {
t.Fatalf("expected version %q, got %q", tfv, v)
}

// TODO: test/assert provider info

// force execution / skip cache as well
v, _, err = tf.Version(ctx, true)
if err != nil {
t.Fatal(err)
}
if v.String() != tfv {
t.Fatalf("expected version %q, got %q", tfv, v)
}
})
}
}

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))
}
})
}
}

0 comments on commit 4898022

Please sign in to comment.