Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tfexec: Add -allow-deferral experimental options to Plan and Apply commands #447

Merged
merged 10 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another GH workflow related change, it seems that setup-go is taking an extra 2 minutes to setup Go 1.22.x on windows-latest, not leaving enough time for the rest of the job to run 🙂 : https://github.com/hashicorp/terraform-exec/actions/runs/8914407327/job/24482771576?pr=447

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

Comment on lines +43 to +46
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping to avoid this error: https://github.com/hashicorp/terraform-exec/actions/runs/8914271134/job/24481540878?pr=447

An unfortunate situation where the latest macos GHA runners now are macOS 14 and ARM64 architecture machines: https://github.blog/changelog/2024-04-01-macos-14-sonoma-is-generally-available-and-the-latest-macos-runner-image/. We ran into this issue on setup-terraform last week: hashicorp/setup-terraform#409

I'm not sure if we want to default fallback to amd64 or if skipping this on arm64 is acceptable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think skipping on arm64 is acceptable given that the nature of the test is to check for version differences, not so much architecture differences, and the former is already being checked on other platforms as well.

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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would've probably called this versionIsPrerelease() or something along these lines, as that's what the function actually does but it's also not exposed API to downstream so I'm not too worried about the name here.

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