Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds version output parsing #7

Merged
merged 1 commit into from
Jul 21, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
41 changes: 10 additions & 31 deletions tfexec/terraform.go
Original file line number Diff line number Diff line change
@@ -1,30 +1,33 @@
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 map[string]string
execPath string
workingDir string
env map[string]string

logger *log.Logger
logPath string

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 @@ -45,13 +48,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 Down Expand Up @@ -85,20 +81,3 @@ func (tf *Terraform) SetLogPath(path string) error {
tf.logPath = path
return nil
}

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the note could be suppressed by disabling checkpoint when running under test, which would make the tests less fragile.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And perhaps we should even disable checkpoint for version commands by default as the advice is ignored and it's not shown to the end-user anyway?

`,
},
} {
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))
}
})
}
}