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)
+			}
+		})
+	}
+}