diff --git a/go.mod b/go.mod index 44a5ecf2..8ee69e15 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index e35e93c9..79ef9b39 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/tfexec/terraform.go b/tfexec/terraform.go index 065c26e7..c3199626 100644 --- a/tfexec/terraform.go +++ b/tfexec/terraform.go @@ -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") } @@ -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 } @@ -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 -} diff --git a/tfexec/version.go b/tfexec/version.go new file mode 100644 index 00000000..b64604bd --- /dev/null +++ b/tfexec/version.go @@ -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[0-9]+(?:\.[0-9]+)*(?:-[A-Za-z0-9\.]+)?)` + + versionOutputRe = regexp.MustCompile(`^Terraform ` + simpleVersionRe) + providerVersionOutputRe = regexp.MustCompile(`(\n\+ provider[\. ](?P\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 +} diff --git a/tfexec/version_test.go b/tfexec/version_test.go new file mode 100644 index 00000000..0badde39 --- /dev/null +++ b/tfexec/version_test.go @@ -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)) + } + }) + } +}