| // Copyright (c) HashiCorp, Inc. |
| // SPDX-License-Identifier: MPL-2.0 |
| |
| package tfexec |
| |
| import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| "fmt" |
| "regexp" |
| "strconv" |
| "strings" |
| |
| "github.com/hashicorp/go-version" |
| tfjson "github.com/hashicorp/terraform-json" |
| ) |
| |
| var ( |
| tf0_4_1 = version.Must(version.NewVersion("0.4.1")) |
| tf0_5_0 = version.Must(version.NewVersion("0.5.0")) |
| tf0_6_13 = version.Must(version.NewVersion("0.6.13")) |
| tf0_7_7 = version.Must(version.NewVersion("0.7.7")) |
| tf0_8_0 = version.Must(version.NewVersion("0.8.0")) |
| tf0_10_0 = version.Must(version.NewVersion("0.10.0")) |
| tf0_12_0 = version.Must(version.NewVersion("0.12.0")) |
| tf0_13_0 = version.Must(version.NewVersion("0.13.0")) |
| tf0_14_0 = version.Must(version.NewVersion("0.14.0")) |
| tf0_15_0 = version.Must(version.NewVersion("0.15.0")) |
| tf0_15_2 = version.Must(version.NewVersion("0.15.2")) |
| tf0_15_3 = version.Must(version.NewVersion("0.15.3")) |
| tf1_1_0 = version.Must(version.NewVersion("1.1.0")) |
| tf1_4_0 = version.Must(version.NewVersion("1.4.0")) |
| tf1_6_0 = version.Must(version.NewVersion("1.6.0")) |
| ) |
| |
| // 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) { |
| versionCmd := tf.buildTerraformCmd(ctx, nil, "version", "-json") |
| |
| var outBuf bytes.Buffer |
| versionCmd.Stdout = &outBuf |
| |
| err := tf.runTerraformCmd(ctx, versionCmd) |
| if err != nil { |
| return nil, nil, err |
| } |
| |
| tfVersion, providerVersions, err := parseJsonVersionOutput(outBuf.Bytes()) |
| if err != nil { |
| if _, ok := err.(*json.SyntaxError); ok { |
| return tf.versionFromPlaintext(ctx) |
| } |
| } |
| |
| return tfVersion, providerVersions, err |
| } |
| |
| func parseJsonVersionOutput(stdout []byte) (*version.Version, map[string]*version.Version, error) { |
| var out tfjson.VersionOutput |
| err := json.Unmarshal(stdout, &out) |
| if err != nil { |
| return nil, nil, err |
| } |
| |
| tfVersion, err := version.NewVersion(out.Version) |
| if err != nil { |
| return nil, nil, fmt.Errorf("unable to parse version %q: %w", out.Version, err) |
| } |
| |
| providerVersions := make(map[string]*version.Version, 0) |
| for provider, versionStr := range out.ProviderSelections { |
| v, err := version.NewVersion(versionStr) |
| if err != nil { |
| return nil, nil, fmt.Errorf("unable to parse %q version %q: %w", |
| provider, versionStr, err) |
| } |
| providerVersions[provider] = v |
| } |
| |
| return tfVersion, providerVersions, nil |
| } |
| |
| func (tf *Terraform) versionFromPlaintext(ctx context.Context) (*version.Version, map[string]*version.Version, error) { |
| versionCmd := tf.buildTerraformCmd(ctx, nil, "version") |
| |
| var outBuf strings.Builder |
| versionCmd.Stdout = &outBuf |
| |
| err := tf.runTerraformCmd(ctx, versionCmd) |
| if err != nil { |
| return nil, nil, err |
| } |
| |
| tfVersion, providerVersions, err := parsePlaintextVersionOutput(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 parsePlaintextVersionOutput(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 provider 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 |
| } |
| |
| func errorVersionString(v *version.Version) string { |
| if v == nil { |
| return "-" |
| } |
| return v.String() |
| } |
| |
| // compatible asserts compatibility of the cached terraform version with the executable, and returns a well known error if not. |
| func (tf *Terraform) compatible(ctx context.Context, minInclusive *version.Version, maxExclusive *version.Version) error { |
| tfv, _, err := tf.Version(ctx, false) |
| if err != nil { |
| return err |
| } |
| if ok := versionInRange(tfv, minInclusive, maxExclusive); !ok { |
| return &ErrVersionMismatch{ |
| MinInclusive: errorVersionString(minInclusive), |
| MaxExclusive: errorVersionString(maxExclusive), |
| Actual: errorVersionString(tfv), |
| } |
| } |
| |
| return nil |
| } |
| |
| func stripPrereleaseAndMeta(v *version.Version) *version.Version { |
| if v == nil { |
| return nil |
| } |
| segs := []string{} |
| for _, s := range v.Segments() { |
| segs = append(segs, strconv.Itoa(s)) |
| } |
| vs := strings.Join(segs, ".") |
| clean, _ := version.NewVersion(vs) |
| return clean |
| } |
| |
| // versionInRange checks compatibility of the Terraform version. The minimum is inclusive and the max |
| // is exclusive, equivalent to min <= expected version < max. |
| // |
| // Pre-release information is ignored for comparison. |
| func versionInRange(tfv *version.Version, minInclusive *version.Version, maxExclusive *version.Version) bool { |
| if minInclusive == nil && maxExclusive == nil { |
| return true |
| } |
| tfv = stripPrereleaseAndMeta(tfv) |
| minInclusive = stripPrereleaseAndMeta(minInclusive) |
| maxExclusive = stripPrereleaseAndMeta(maxExclusive) |
| if minInclusive != nil && !tfv.GreaterThanOrEqual(minInclusive) { |
| return false |
| } |
| if maxExclusive != nil && !tfv.LessThan(maxExclusive) { |
| return false |
| } |
| |
| return true |
| } |