From a9c9728194c4c0aa04da955610d9cf0ada95e2bd Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 17 May 2024 03:07:59 -0400 Subject: [PATCH] tfexec: Add `-allow-deferral` experimental options to `Plan` and `Apply` commands (#447) --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 5 +++ tfexec/apply.go | 29 +++++++++++++-- tfexec/apply_test.go | 32 ++++++++++++++++ tfexec/force_unlock_test.go | 5 +++ tfexec/internal/testutil/tfcache.go | 3 ++ tfexec/options.go | 12 ++++++ tfexec/plan.go | 48 +++++++++++++++++------- tfexec/plan_test.go | 31 +++++++++++++++ tfexec/version.go | 17 +++++++++ tfexec/version_test.go | 58 +++++++++++++++++++++++++++++ 11 files changed, 223 insertions(+), 19 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dc7ea6aa..ed3cfe0f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -80,7 +80,7 @@ jobs: - 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 dc0fa167..c6f565c2 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 2c5a6d07..7a6ea923 100644 --- a/tfexec/apply.go +++ b/tfexec/apply.go @@ -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 @@ -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...) @@ -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 } diff --git a/tfexec/apply_test.go b/tfexec/apply_test.go index 8e2ddeed..fe0420ad 100644 --- a/tfexec/apply_test.go +++ b/tfexec/apply_test.go @@ -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) + }) +} diff --git a/tfexec/force_unlock_test.go b/tfexec/force_unlock_test.go index 34e3efe6..a124167c 100644 --- a/tfexec/force_unlock_test.go +++ b/tfexec/force_unlock_test.go @@ -5,6 +5,7 @@ package tfexec import ( "context" + "runtime" "testing" "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" @@ -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)) diff --git a/tfexec/internal/testutil/tfcache.go b/tfexec/internal/testutil/tfcache.go index ff9ceb00..82d4ef09 100644 --- a/tfexec/internal/testutil/tfcache.go +++ b/tfexec/internal/testutil/tfcache.go @@ -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" diff --git a/tfexec/options.go b/tfexec/options.go index d783027a..339bf39e 100644 --- a/tfexec/options.go +++ b/tfexec/options.go @@ -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 diff --git a/tfexec/plan.go b/tfexec/plan.go index 946ce8d0..c2ec1f9e 100644 --- a/tfexec/plan.go +++ b/tfexec/plan.go @@ -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{ @@ -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. // @@ -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 } diff --git a/tfexec/plan_test.go b/tfexec/plan_test.go index b0c404ef..e48dbe4b 100644 --- a/tfexec/plan_test.go +++ b/tfexec/plan_test.go @@ -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) + }) +} diff --git a/tfexec/version.go b/tfexec/version.go index 4ba4f6ea..87addd1e 100644 --- a/tfexec/version.go +++ b/tfexec/version.go @@ -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 @@ -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 diff --git a/tfexec/version_test.go b/tfexec/version_test.go index cc057ffe..4d7db857 100644 --- a/tfexec/version_test.go +++ b/tfexec/version_test.go @@ -9,6 +9,7 @@ import ( "fmt" "path/filepath" "runtime" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -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) + } + }) + } +}