tfexec: Add `-allow-deferral` experimental options to `Plan` and `Apply` commands (#447)
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index dc7ea6a..ed3cfe0 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -80,7 +80,7 @@
- resolve-versions
- static-checks
runs-on: ${{ matrix.os }}
- timeout-minutes: 10
+ timeout-minutes: 20
strategy:
fail-fast: false
matrix:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc0fa16..c6f565c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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:
diff --git a/tfexec/apply.go b/tfexec/apply.go
index 2c5a6d0..7a6ea92 100644
--- a/tfexec/apply.go
+++ b/tfexec/apply.go
@@ -12,10 +12,11 @@
)
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
@@ -105,6 +106,10 @@
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...)
@@ -232,6 +237,22 @@
}
}
+ 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
}
diff --git a/tfexec/apply_test.go b/tfexec/apply_test.go
index 8e2ddee..fe0420a 100644
--- a/tfexec/apply_test.go
+++ b/tfexec/apply_test.go
@@ -150,3 +150,35 @@
}, 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)
+ })
+}
diff --git a/tfexec/force_unlock_test.go b/tfexec/force_unlock_test.go
index 34e3efe..a124167 100644
--- a/tfexec/force_unlock_test.go
+++ b/tfexec/force_unlock_test.go
@@ -5,6 +5,7 @@
import (
"context"
+ "runtime"
"testing"
"github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
@@ -39,6 +40,10 @@
// 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))
diff --git a/tfexec/internal/testutil/tfcache.go b/tfexec/internal/testutil/tfcache.go
index ff9ceb0..82d4ef0 100644
--- a/tfexec/internal/testutil/tfcache.go
+++ b/tfexec/internal/testutil/tfcache.go
@@ -24,6 +24,9 @@
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"
diff --git a/tfexec/options.go b/tfexec/options.go
index d783027..339bf39 100644
--- a/tfexec/options.go
+++ b/tfexec/options.go
@@ -7,6 +7,18 @@
"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
diff --git a/tfexec/plan.go b/tfexec/plan.go
index 946ce8d..c2ec1f9 100644
--- a/tfexec/plan.go
+++ b/tfexec/plan.go
@@ -12,20 +12,21 @@
)
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{
@@ -97,6 +98,10 @@
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.
//
@@ -243,6 +248,21 @@
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
}
diff --git a/tfexec/plan_test.go b/tfexec/plan_test.go
index b0c404e..e48dbe4 100644
--- a/tfexec/plan_test.go
+++ b/tfexec/plan_test.go
@@ -178,3 +178,34 @@
}, 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)
+ })
+}
diff --git a/tfexec/version.go b/tfexec/version.go
index 4ba4f6e..87addd1 100644
--- a/tfexec/version.go
+++ b/tfexec/version.go
@@ -33,6 +33,7 @@
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
@@ -180,6 +181,22 @@
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
diff --git a/tfexec/version_test.go b/tfexec/version_test.go
index cc057ff..4d7db85 100644
--- a/tfexec/version_test.go
+++ b/tfexec/version_test.go
@@ -9,6 +9,7 @@
"fmt"
"path/filepath"
"runtime"
+ "strings"
"testing"
"github.com/google/go-cmp/cmp"
@@ -293,3 +294,60 @@
})
}
}
+
+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)
+ }
+ })
+ }
+}