Skip to content

Commit

Permalink
tfexec: Add -allow-deferral experimental options to Plan and `App…
Browse files Browse the repository at this point in the history
…ly` commands (#447)
  • Loading branch information
austinvalle authored May 17, 2024
1 parent c07c678 commit a9c9728
Show file tree
Hide file tree
Showing 11 changed files with 223 additions and 19 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ jobs:
- resolve-versions
- static-checks
runs-on: ${{ matrix.os }}
timeout-minutes: 10
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# 0.21.0 (Unreleased)

ENHANCEMENTS:
- tfexec: Add `-allow-deferral` to `(Terraform).Apply()` and `(Terraform).Plan()` methods ([#447](https://github.com/hashicorp/terraform-exec/pull/447))

# 0.20.0 (December 20, 2023)

ENHANCEMENTS:
Expand Down
29 changes: 25 additions & 4 deletions tfexec/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import (
)

type applyConfig struct {
backup string
destroy bool
dirOrPlan string
lock bool
allowDeferral bool
backup string
destroy bool
dirOrPlan string
lock bool

// LockTimeout must be a string with time unit, e.g. '10s'
lockTimeout string
Expand Down Expand Up @@ -105,6 +106,10 @@ func (opt *DestroyFlagOption) configureApply(conf *applyConfig) {
conf.destroy = opt.destroy
}

func (opt *AllowDeferralOption) configureApply(conf *applyConfig) {
conf.allowDeferral = opt.allowDeferral
}

// Apply represents the terraform apply subcommand.
func (tf *Terraform) Apply(ctx context.Context, opts ...ApplyOption) error {
cmd, err := tf.applyCmd(ctx, opts...)
Expand Down Expand Up @@ -232,6 +237,22 @@ func (tf *Terraform) buildApplyArgs(ctx context.Context, c applyConfig) ([]strin
}
}

if c.allowDeferral {
// Ensure the version is later than 1.9.0
err := tf.compatible(ctx, tf1_9_0, nil)
if err != nil {
return nil, fmt.Errorf("-allow-deferral is an experimental option introduced in Terraform 1.9.0: %w", err)
}

// Ensure the version has experiments enabled (alpha or dev builds)
err = tf.experimentsEnabled(ctx)
if err != nil {
return nil, fmt.Errorf("-allow-deferral is only available in experimental Terraform builds: %w", err)
}

args = append(args, "-allow-deferral")
}

return args, nil
}

Expand Down
32 changes: 32 additions & 0 deletions tfexec/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,35 @@ func TestApplyJSONCmd(t *testing.T) {
}, nil, applyCmd)
})
}

func TestApplyCmd_AllowDeferral(t *testing.T) {
td := t.TempDir()

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

// empty env, to avoid environ mismatch in testing
tf.SetEnv(map[string]string{})

t.Run("allow deferrals during apply", func(t *testing.T) {
applyCmd, err := tf.applyCmd(context.Background(),
AllowDeferral(true),
)
if err != nil {
t.Fatal(err)
}

assertCmd(t, []string{
"apply",
"-no-color",
"-auto-approve",
"-input=false",
"-lock=true",
"-parallelism=10",
"-refresh=true",
"-allow-deferral",
}, nil, applyCmd)
})
}
5 changes: 5 additions & 0 deletions tfexec/force_unlock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package tfexec

import (
"context"
"runtime"
"testing"

"github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
Expand Down Expand Up @@ -39,6 +40,10 @@ func TestForceUnlockCmd(t *testing.T) {
// The optional final positional [DIR] argument is available
// until v0.15.0.
func TestForceUnlockCmd_pre015(t *testing.T) {
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
t.Skip("Terraform for darwin/arm64 is not available until v1")
}

td := t.TempDir()

tf, err := NewTerraform(td, tfVersion(t, testutil.Latest014))
Expand Down
3 changes: 3 additions & 0 deletions tfexec/internal/testutil/tfcache.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const (
Latest_v1_1 = "1.1.9"
Latest_v1_5 = "1.5.3"
Latest_v1_6 = "1.6.0-alpha20230719"

Beta_v1_8 = "1.8.0-beta1"
Alpha_v1_9 = "1.9.0-alpha20240404"
)

const appendUserAgent = "tfexec-testutil"
Expand Down
12 changes: 12 additions & 0 deletions tfexec/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ import (
"encoding/json"
)

// AllowDeferralOption represents the -allow-deferral flag. This flag is only enabled in
// experimental builds of Terraform. (alpha or built via source with experiments enabled)
type AllowDeferralOption struct {
allowDeferral bool
}

// AllowDeferral represents the -allow-deferral flag. This flag is only enabled in
// experimental builds of Terraform. (alpha or built via source with experiments enabled)
func AllowDeferral(allowDeferral bool) *AllowDeferralOption {
return &AllowDeferralOption{allowDeferral}
}

// AllowMissingConfigOption represents the -allow-missing-config flag.
type AllowMissingConfigOption struct {
allowMissingConfig bool
Expand Down
48 changes: 34 additions & 14 deletions tfexec/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,21 @@ import (
)

type planConfig struct {
destroy bool
dir string
lock bool
lockTimeout string
out string
parallelism int
reattachInfo ReattachInfo
refresh bool
refreshOnly bool
replaceAddrs []string
state string
targets []string
vars []string
varFiles []string
allowDeferral bool
destroy bool
dir string
lock bool
lockTimeout string
out string
parallelism int
reattachInfo ReattachInfo
refresh bool
refreshOnly bool
replaceAddrs []string
state string
targets []string
vars []string
varFiles []string
}

var defaultPlanOptions = planConfig{
Expand Down Expand Up @@ -97,6 +98,10 @@ func (opt *DestroyFlagOption) configurePlan(conf *planConfig) {
conf.destroy = opt.destroy
}

func (opt *AllowDeferralOption) configurePlan(conf *planConfig) {
conf.allowDeferral = opt.allowDeferral
}

// Plan executes `terraform plan` with the specified options and waits for it
// to complete.
//
Expand Down Expand Up @@ -243,6 +248,21 @@ func (tf *Terraform) buildPlanArgs(ctx context.Context, c planConfig) ([]string,
args = append(args, "-var", v)
}
}
if c.allowDeferral {
// Ensure the version is later than 1.9.0
err := tf.compatible(ctx, tf1_9_0, nil)
if err != nil {
return nil, fmt.Errorf("-allow-deferral is an experimental option introduced in Terraform 1.9.0: %w", err)
}

// Ensure the version has experiments enabled (alpha or dev builds)
err = tf.experimentsEnabled(ctx)
if err != nil {
return nil, fmt.Errorf("-allow-deferral is only available in experimental Terraform builds: %w", err)
}

args = append(args, "-allow-deferral")
}

return args, nil
}
Expand Down
31 changes: 31 additions & 0 deletions tfexec/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,34 @@ func TestPlanJSONCmd(t *testing.T) {
}, nil, planCmd)
})
}

func TestPlanCmd_AllowDeferral(t *testing.T) {
td := t.TempDir()

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

// empty env, to avoid environ mismatch in testing
tf.SetEnv(map[string]string{})

t.Run("allow deferrals during plan", func(t *testing.T) {
planCmd, err := tf.planCmd(context.Background(), AllowDeferral(true))
if err != nil {
t.Fatal(err)
}

assertCmd(t, []string{
"plan",
"-no-color",
"-input=false",
"-detailed-exitcode",
"-lock-timeout=0s",
"-lock=true",
"-parallelism=10",
"-refresh=true",
"-allow-deferral",
}, nil, planCmd)
})
}
17 changes: 17 additions & 0 deletions tfexec/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ var (
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"))
tf1_9_0 = version.Must(version.NewVersion("1.9.0"))
)

// Version returns structured output from the terraform version command including both the Terraform CLI version
Expand Down Expand Up @@ -180,6 +181,22 @@ func (tf *Terraform) compatible(ctx context.Context, minInclusive *version.Versi
return nil
}

// experimentsEnabled asserts the cached terraform version has experiments enabled in the executable,
// and returns a well known error if not. Experiments are enabled in alpha and (potentially) dev builds of Terraform.
func (tf *Terraform) experimentsEnabled(ctx context.Context) error {
tfv, _, err := tf.Version(ctx, false)
if err != nil {
return err
}

preRelease := tfv.Prerelease()
if preRelease == "dev" || strings.Contains(preRelease, "alpha") {
return nil
}

return fmt.Errorf("experiments are not enabled in version %s, as it's not an alpha or dev build", errorVersionString(tfv))
}

func stripPrereleaseAndMeta(v *version.Version) *version.Version {
if v == nil {
return nil
Expand Down
58 changes: 58 additions & 0 deletions tfexec/version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"fmt"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/google/go-cmp/cmp"
Expand Down Expand Up @@ -293,3 +294,60 @@ func TestCompatible(t *testing.T) {
})
}
}

func TestExperimentsEnabled(t *testing.T) {
testCases := map[string]struct {
tfVersion *version.Version
expectedError error
}{
"experiments-enabled-in-1.9.0-alpha20240404": {
tfVersion: version.Must(version.NewVersion(testutil.Alpha_v1_9)),
},
"experiments-disabled-in-1.8.0-beta1": {
tfVersion: version.Must(version.NewVersion(testutil.Beta_v1_8)),
expectedError: errors.New("experiments are not enabled in version 1.8.0-beta1, as it's not an alpha or dev build"),
},
"experiments-disabled-in-1.5.3": {
tfVersion: version.Must(version.NewVersion(testutil.Latest_v1_5)),
expectedError: errors.New("experiments are not enabled in version 1.5.3, as it's not an alpha or dev build"),
},
}
for name, testCase := range testCases {
name, testCase := name, testCase
t.Run(name, func(t *testing.T) {
ev := &releases.ExactVersion{
Product: product.Terraform,
Version: testCase.tfVersion,
}
ev.SetLogger(testutil.TestLogger())

ctx := context.Background()
t.Cleanup(func() { ev.Remove(ctx) })

tfBinPath, err := ev.Install(ctx)
if err != nil {
t.Fatal(err)
}

tf, err := NewTerraform(filepath.Dir(tfBinPath), tfBinPath)
if err != nil {
t.Fatal(err)
}

err = tf.experimentsEnabled(context.Background())
if err != nil {
if testCase.expectedError == nil {
t.Fatalf("expected no error, got: %s", err)
}

if !strings.Contains(err.Error(), testCase.expectedError.Error()) {
t.Fatalf("expected error %q, got: %s", testCase.expectedError, err)
}
}

if err == nil && testCase.expectedError != nil {
t.Fatalf("got no error, expected: %s", testCase.expectedError)
}
})
}
}

0 comments on commit a9c9728

Please sign in to comment.