Skip to content
Open
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
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -520,13 +520,24 @@ TF_CLI_ARGS_plan="-var-file=production.tfvars" tfautomv

#### Using Terragrunt instead of Terraform

You can tell `tfautomv` to use the Terragrunt CLI instead of the Terraform CLI
with the `--terraform-bin` flag:
You can use `tfautomv` with Terragrunt in two ways:

**Option 1: Using the `--terragrunt` flag (recommended)**

```bash
tfautomv --terragrunt
```

This automatically uses the `terragrunt` binary and enables terragrunt-specific compatibility features, including proper version parsing that handles terragrunt's output format.

**Option 2: Using the `--terraform-bin` flag**

```bash
tfautomv --terraform-bin=terragrunt
```

This approach works but may encounter version parsing issues with some terragrunt versions. The `--terragrunt` flag is recommended for better compatibility.

#### Using OpenTofu instead of Terraform

OpenTofu is officially supported! You can use OpenTofu with the `--terraform-bin` flag:
Expand Down
20 changes: 19 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ var tfautomvVersion string
func run() error {
parseFlags()

// Handle --terragrunt flag
if useTerragrunt {
if flag.Lookup("terraform-bin").Changed {
return fmt.Errorf("--terragrunt cannot be used with --terraform-bin")
}
terraformBin = "terragrunt"
}

workdirs := flag.Args()
if len(workdirs) == 0 {
workdirs = []string{"."}
Expand Down Expand Up @@ -69,7 +77,12 @@ func run() error {
return fmt.Errorf("--preplanned-file can only be used with --preplanned")
}

tfVersion, err := terraform.GetVersion(ctx, terraform.WithTerraformBin(terraformBin))
versionOptions := []terraform.Option{terraform.WithTerraformBin(terraformBin)}
if useTerragrunt {
versionOptions = append(versionOptions, terraform.WithTerragrunt(true))
}

tfVersion, err := terraform.GetVersion(ctx, versionOptions...)
if err != nil {
return fmt.Errorf("failed to get Terraform version: %w", err)
}
Expand Down Expand Up @@ -113,6 +126,9 @@ func run() error {
terraform.WithSkipInit(skipInit),
terraform.WithSkipRefresh(skipRefresh),
}
if useTerragrunt {
terraformOptions = append(terraformOptions, terraform.WithTerragrunt(true))
}

var plans []engine.Plan

Expand Down Expand Up @@ -201,6 +217,7 @@ var (
skipInit bool
skipRefresh bool
terraformBin string
useTerragrunt bool
verbosity int
preplannedFile string
usePreplanned bool
Expand All @@ -214,6 +231,7 @@ func parseFlags() {
flag.BoolVarP(&skipInit, "skip-init", "s", false, "skip running terraform init")
flag.BoolVarP(&skipRefresh, "skip-refresh", "S", false, "skip running terraform refresh")
flag.StringVar(&terraformBin, "terraform-bin", "terraform", "terraform binary to use")
flag.BoolVar(&useTerragrunt, "terragrunt", false, "use terragrunt instead of terraform (sets --terraform-bin=terragrunt and enables terragrunt-specific version parsing)")
flag.CountVarP(&verbosity, "verbosity", "v", "increase verbosity (can be specified multiple times)")
flag.BoolVar(&usePreplanned, "preplanned", false, "use existing plan files instead of running terraform plan")
flag.StringVar(&preplannedFile, "preplanned-file", "tfplan.bin", "plan file name when using --preplanned")
Expand Down
17 changes: 13 additions & 4 deletions pkg/terraform/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import (
)

type settings struct {
workdir string
terraformBin string
skipInit bool
skipRefresh bool
workdir string
terraformBin string
skipInit bool
skipRefresh bool
useTerragrunt bool
}

// An Option configures how Terraform commands are run.
Expand Down Expand Up @@ -62,6 +63,14 @@ func WithSkipRefresh(skipRefresh bool) Option {
}
}

// WithTerragrunt configures whether to use terragrunt-specific behavior.
// This enables custom version parsing that works with terragrunt's output format.
func WithTerragrunt(useTerragrunt bool) Option {
return func(s *settings) {
s.useTerragrunt = useTerragrunt
}
}

func (s *settings) apply(opts []Option) {
for _, opt := range opts {
opt(s)
Expand Down
38 changes: 38 additions & 0 deletions pkg/terraform/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package terraform
import (
"context"
"fmt"
"os/exec"
"regexp"
"strings"

"github.com/hashicorp/go-version"
"github.com/hashicorp/terraform-exec/tfexec"
Expand All @@ -19,6 +22,11 @@ func GetVersion(ctx context.Context, opts ...Option) (*version.Version, error) {
return nil, fmt.Errorf("invalid options: %w", err)
}

// Use custom terragrunt version parsing if terragrunt mode is enabled
if settings.useTerragrunt {
return getTerragruntVersion(ctx, settings)
}

tf, err := tfexec.NewTerraform(settings.workdir, settings.terraformBin)
if err != nil {
return nil, fmt.Errorf("failed to create Terraform executor: %w", err)
Expand All @@ -31,3 +39,33 @@ func GetVersion(ctx context.Context, opts ...Option) (*version.Version, error) {

return version, nil
}

// getTerragruntVersion obtains the version of terragrunt using custom parsing
// that handles terragrunt's output format and deprecation warnings.
func getTerragruntVersion(ctx context.Context, settings settings) (*version.Version, error) {
// Use "terragrunt run -- version" to avoid deprecation warning
cmd := exec.CommandContext(ctx, settings.terraformBin, "run", "--", "version")
cmd.Dir = settings.workdir

output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to run terragrunt version: %w", err)
}

// Parse the version from the output
// Expected format: "Terraform v1.12.2\non darwin_arm64\n"
versionRegex := regexp.MustCompile(`Terraform v(\d+\.\d+\.\d+)`)
matches := versionRegex.FindStringSubmatch(string(output))

if len(matches) < 2 {
return nil, fmt.Errorf("unable to parse version from terragrunt output: %s", strings.TrimSpace(string(output)))
}

versionStr := matches[1]
parsedVersion, err := version.NewSemver(versionStr)
if err != nil {
return nil, fmt.Errorf("failed to parse version %q: %w", versionStr, err)
}

return parsedVersion, nil
}
108 changes: 108 additions & 0 deletions pkg/terraform/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package terraform

import (
"context"
"regexp"
"testing"

"github.com/hashicorp/go-version"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestGetTerragruntVersion(t *testing.T) {
tests := []struct {
name string
mockOutput string
expectedError bool
expectedVersion string
}{
{
name: "valid terragrunt output",
mockOutput: "Terraform v1.12.2\non darwin_arm64\n",
expectedError: false,
expectedVersion: "1.12.2",
},
{
name: "valid terragrunt output with different version",
mockOutput: "Terraform v1.5.7\non linux_amd64\n",
expectedError: false,
expectedVersion: "1.5.7",
},
{
name: "invalid output format",
mockOutput: "Some random output\nwithout version\n",
expectedError: true,
expectedVersion: "",
},
{
name: "empty output",
mockOutput: "",
expectedError: true,
expectedVersion: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Test the regex parsing logic directly
// We can't easily test the full function without mocking exec.Command
// So we'll test the parsing logic separately

if tt.expectedError {
// For error cases, we expect the regex to not match
versionRegex := `Terraform v(\d+\.\d+\.\d+)`
matches := findVersionInOutput(tt.mockOutput, versionRegex)
assert.Empty(t, matches, "Expected no version matches for invalid output")
} else {
// For success cases, we expect the regex to match and parse correctly
versionRegex := `Terraform v(\d+\.\d+\.\d+)`
matches := findVersionInOutput(tt.mockOutput, versionRegex)
require.Len(t, matches, 2, "Expected version regex to match")

versionStr := matches[1]
assert.Equal(t, tt.expectedVersion, versionStr)

// Verify the version can be parsed
parsedVersion, err := version.NewSemver(versionStr)
require.NoError(t, err)
assert.Equal(t, tt.expectedVersion, parsedVersion.String())
}
})
}
}

// Helper function to test the regex parsing logic
func findVersionInOutput(output, pattern string) []string {
// This mimics the logic in getTerragruntVersion
versionRegex := regexp.MustCompile(pattern)
return versionRegex.FindStringSubmatch(output)
}

func TestGetVersion_WithTerragrunt(t *testing.T) {
// This test requires terragrunt to be installed
// Skip if terragrunt is not available
ctx := context.Background()

// Test that the terragrunt option works
settings := settings{
workdir: ".",
terraformBin: "terragrunt",
useTerragrunt: true,
}

// We can't easily test this without a real terragrunt installation
// and a proper terragrunt.hcl file, so we'll skip this test in CI
// This is more of an integration test
t.Skip("Integration test - requires terragrunt installation and proper setup")

tfVersion, err := getTerragruntVersion(ctx, settings)
if err != nil {
t.Logf("Terragrunt not available or not properly configured: %v", err)
t.Skip("Terragrunt not available")
}

assert.NotNil(t, tfVersion)
minVersion := version.Must(version.NewSemver("1.0.0"))
assert.True(t, tfVersion.GreaterThan(minVersion))
}
56 changes: 56 additions & 0 deletions test/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,62 @@ inputs = {
assert.Equal(t, 0, changeCount)
}

func TestE2E_TerragruntFlag(t *testing.T) {
workdir := t.TempDir()
codePath := filepath.Join(workdir, "main.tf")
terragruntConfigPath := filepath.Join(workdir, "terragrunt.hcl")

originalCode := `
variable "prefix" {
type = string
}
resource "random_pet" "original_first" {
prefix = var.prefix
length = 1
}
resource "random_pet" "original_second" {
prefix = var.prefix
length = 2
}
resource "random_pet" "original_third" {
prefix = var.prefix
length = 3
}`

refactoredCode := `
variable "prefix" {
type = string
}
resource "random_pet" "refactored_first" {
prefix = var.prefix
length = 1
}
resource "random_pet" "refactored_second" {
prefix = var.prefix
length = 2
}
resource "random_pet" "refactored_third" {
prefix = var.prefix
length = 3
}`

terragruntConfig := `
inputs = {
prefix = "my-"
}`

writeCode(t, codePath, originalCode)
writeCode(t, terragruntConfigPath, terragruntConfig)
terragruntInitAndApply(t, workdir)
writeCode(t, codePath, refactoredCode)

// Test using the new --terragrunt flag instead of --terraform-bin=terragrunt
runTfautomvPipeSh(t, workdir, []string{"--terragrunt"})

changeCount := countPlannedChanges(terragruntPlan(t, workdir))
assert.Equal(t, 0, changeCount)
}

func TestE2E_OpenTofu(t *testing.T) {
checkOpentofuAvailable(t)

Expand Down
Loading