diff --git a/cli/commands/stack/action.go b/cli/commands/stack/action.go index be21ffbbbc..54e5918d5c 100644 --- a/cli/commands/stack/action.go +++ b/cli/commands/stack/action.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" + runall "github.com/gruntwork-io/terragrunt/cli/commands/run-all" + "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/config" @@ -19,7 +21,7 @@ import ( ) const ( - stackCacheDir = ".terragrunt-stack" + stackDir = ".terragrunt-stack" defaultStackFile = "terragrunt.stack.hcl" dirPerm = 0755 ) @@ -34,8 +36,31 @@ func RunGenerate(ctx context.Context, opts *options.TerragruntOptions) error { return generateStack(ctx, opts) } +// Run execute stack command. +func Run(ctx context.Context, opts *options.TerragruntOptions) error { + stacksEnabled := opts.Experiments[experiment.Stacks] + if !stacksEnabled.Enabled { + return errors.New("stacks experiment is not enabled use --experiment stacks to enable it") + } + + if err := RunGenerate(ctx, opts); err != nil { + return err + } + + // prepare options for execution + // navigate to stack directory + opts.WorkingDir = filepath.Join(opts.WorkingDir, stackDir) + // remove 0 element from args + opts.TerraformCliArgs = opts.TerraformCliArgs[1:] + opts.TerraformCommand = opts.TerraformCliArgs[0] + opts.OriginalTerraformCommand = strings.Join(opts.TerraformCliArgs, " ") + + return runall.Run(ctx, opts) +} + func generateStack(ctx context.Context, opts *options.TerragruntOptions) error { opts.TerragruntStackConfigPath = filepath.Join(opts.WorkingDir, defaultStackFile) + opts.Logger.Infof("Generating stack from %s", opts.TerragruntStackConfigPath) stackFile, err := config.ReadStackConfigFile(ctx, opts) if err != nil { @@ -48,13 +73,16 @@ func generateStack(ctx context.Context, opts *options.TerragruntOptions) error { return nil } + func processStackFile(ctx context.Context, opts *options.TerragruntOptions, stackFile *config.StackConfigFile) error { - baseDir := filepath.Join(opts.WorkingDir, stackCacheDir) + baseDir := filepath.Join(opts.WorkingDir, stackDir) if err := os.MkdirAll(baseDir, dirPerm); err != nil { return errors.New(fmt.Errorf("failed to create base directory: %w", err)) } for _, unit := range stackFile.Units { + opts.Logger.Infof("Processing unit %s", unit.Name) + destPath := filepath.Join(baseDir, unit.Path) dest, err := filepath.Abs(destPath) diff --git a/cli/commands/stack/command.go b/cli/commands/stack/command.go index 6ea8504850..6276a6a196 100644 --- a/cli/commands/stack/command.go +++ b/cli/commands/stack/command.go @@ -10,6 +10,7 @@ const ( // CommandName stack command name. CommandName = "stack" generate = "generate" + run = "run" ) // NewFlags builds the flags for stack. @@ -26,13 +27,20 @@ func NewCommand(opts *options.TerragruntOptions) *cli.Command { Flags: NewFlags(opts).Sort(), Subcommands: cli.Commands{ &cli.Command{ - Name: "generate", - Usage: "Generate the stack file.", + Name: generate, + Usage: "Generate a stack from a terragrunt.stack.hcl file", Action: func(ctx *cli.Context) error { return RunGenerate(ctx.Context, opts.OptionsFromContext(ctx)) }, }, + &cli.Command{ + Name: run, + Usage: "Run a command on the stack generated from the current directory", + Action: func(ctx *cli.Context) error { + return Run(ctx.Context, opts.OptionsFromContext(ctx)) + }, + }, }, Action: func(ctx *cli.Context) error { return cli.ShowCommandHelp(ctx, generate) diff --git a/test/fixtures/stacks/basic/units/chick/main.tf b/test/fixtures/stacks/basic/units/chick/main.tf new file mode 100644 index 0000000000..763f6aaa60 --- /dev/null +++ b/test/fixtures/stacks/basic/units/chick/main.tf @@ -0,0 +1,9 @@ + +resource "local_file" "file" { + content = "chick" + filename = "${path.module}/test.txt" +} + +output "output" { + value = local_file.file.filename +} diff --git a/test/fixtures/stacks/basic/units/chick/terragrunt.hcl b/test/fixtures/stacks/basic/units/chick/terragrunt.hcl index e69de29bb2..3e98bd91b7 100644 --- a/test/fixtures/stacks/basic/units/chick/terragrunt.hcl +++ b/test/fixtures/stacks/basic/units/chick/terragrunt.hcl @@ -0,0 +1,4 @@ + +terraform { + source = "." +} \ No newline at end of file diff --git a/test/fixtures/stacks/basic/units/chicken/main.tf b/test/fixtures/stacks/basic/units/chicken/main.tf new file mode 100644 index 0000000000..6b07342fca --- /dev/null +++ b/test/fixtures/stacks/basic/units/chicken/main.tf @@ -0,0 +1,9 @@ + +resource "local_file" "file" { + content = "chicken" + filename = "${path.module}/test.txt" +} + +output "output" { + value = local_file.file.filename +} diff --git a/test/fixtures/stacks/basic/units/chicken/terragrunt.hcl b/test/fixtures/stacks/basic/units/chicken/terragrunt.hcl index e69de29bb2..3e98bd91b7 100644 --- a/test/fixtures/stacks/basic/units/chicken/terragrunt.hcl +++ b/test/fixtures/stacks/basic/units/chicken/terragrunt.hcl @@ -0,0 +1,4 @@ + +terraform { + source = "." +} \ No newline at end of file diff --git a/test/fixtures/stacks/basic/units/father/main.tf b/test/fixtures/stacks/basic/units/father/main.tf new file mode 100644 index 0000000000..122601dd1c --- /dev/null +++ b/test/fixtures/stacks/basic/units/father/main.tf @@ -0,0 +1,9 @@ + +resource "local_file" "file" { + content = "father" + filename = "${path.module}/test.txt" +} + +output "output" { + value = local_file.file.filename +} diff --git a/test/fixtures/stacks/basic/units/father/terragrunt.hcl b/test/fixtures/stacks/basic/units/father/terragrunt.hcl index e69de29bb2..3e98bd91b7 100644 --- a/test/fixtures/stacks/basic/units/father/terragrunt.hcl +++ b/test/fixtures/stacks/basic/units/father/terragrunt.hcl @@ -0,0 +1,4 @@ + +terraform { + source = "." +} \ No newline at end of file diff --git a/test/fixtures/stacks/basic/units/mother/main.tf b/test/fixtures/stacks/basic/units/mother/main.tf new file mode 100644 index 0000000000..7e99b70efb --- /dev/null +++ b/test/fixtures/stacks/basic/units/mother/main.tf @@ -0,0 +1,9 @@ + +resource "local_file" "file" { + content = "mother" + filename = "${path.module}/test.txt" +} + +output "output" { + value = local_file.file.filename +} diff --git a/test/fixtures/stacks/basic/units/mother/terragrunt.hcl b/test/fixtures/stacks/basic/units/mother/terragrunt.hcl index e69de29bb2..3e98bd91b7 100644 --- a/test/fixtures/stacks/basic/units/mother/terragrunt.hcl +++ b/test/fixtures/stacks/basic/units/mother/terragrunt.hcl @@ -0,0 +1,4 @@ + +terraform { + source = "." +} \ No newline at end of file diff --git a/test/fixtures/stacks/inputs/terragrunt.stack.hcl b/test/fixtures/stacks/inputs/terragrunt.stack.hcl new file mode 100644 index 0000000000..23745e5111 --- /dev/null +++ b/test/fixtures/stacks/inputs/terragrunt.stack.hcl @@ -0,0 +1,9 @@ +unit "unit1" { + source = "units/app" + path = "unit1" +} + +unit "unit2" { + source = "units/app" + path = "unit2" +} diff --git a/test/fixtures/stacks/inputs/units/app/main.tf b/test/fixtures/stacks/inputs/units/app/main.tf new file mode 100644 index 0000000000..d91959dc16 --- /dev/null +++ b/test/fixtures/stacks/inputs/units/app/main.tf @@ -0,0 +1,17 @@ +variable "content" { + type = string +} + +variable "filename" { + type = string + default = "file.txt" +} + +resource "local_file" "file" { + content = var.content + filename = "${path.module}/${var.filename}" +} + +output "output" { + value = local_file.file.filename +} diff --git a/test/fixtures/stacks/inputs/units/app/terragrunt.hcl b/test/fixtures/stacks/inputs/units/app/terragrunt.hcl new file mode 100644 index 0000000000..62089af344 --- /dev/null +++ b/test/fixtures/stacks/inputs/units/app/terragrunt.hcl @@ -0,0 +1,8 @@ + +terraform { + source = "." +} + +inputs = { + content = "content" +} \ No newline at end of file diff --git a/test/integration_stacks_test.go b/test/integration_stacks_test.go index 5267c0f58a..4c9ef72610 100644 --- a/test/integration_stacks_test.go +++ b/test/integration_stacks_test.go @@ -1,8 +1,12 @@ package test_test import ( + "os" + "path/filepath" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/gruntwork-io/terragrunt/test/helpers" @@ -14,6 +18,7 @@ const ( testFixtureStacksLocals = "fixtures/stacks/locals" testFixtureStacksLocalsError = "fixtures/stacks/locals-error" testFixtureStacksRemote = "fixtures/stacks/remote" + testFixtureStacksInputs = "fixtures/stacks/inputs" ) func TestStacksGenerateBasic(t *testing.T) { @@ -24,6 +29,9 @@ func TestStacksGenerateBasic(t *testing.T) { rootPath := util.JoinPath(tmpEnvPath, testFixtureStacksBasic) helpers.RunTerragrunt(t, "terragrunt stack generate --experiment stacks --terragrunt-working-dir "+rootPath) + + path := util.JoinPath(rootPath, ".terragrunt-stack") + validateStackDir(t, path) } func TestStacksGenerateLocals(t *testing.T) { @@ -55,4 +63,115 @@ func TestStacksGenerateRemote(t *testing.T) { rootPath := util.JoinPath(tmpEnvPath, testFixtureStacksRemote) helpers.RunTerragrunt(t, "terragrunt stack generate --experiment stacks --terragrunt-working-dir "+rootPath) + + path := util.JoinPath(rootPath, ".terragrunt-stack") + validateStackDir(t, path) +} + +func TestStacksBasic(t *testing.T) { + t.Parallel() + + helpers.CleanupTerraformFolder(t, testFixtureStacksBasic) + tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStacksBasic) + rootPath := util.JoinPath(tmpEnvPath, testFixtureStacksBasic) + + helpers.RunTerragrunt(t, "terragrunt --experiment stacks stack run apply --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + + path := util.JoinPath(rootPath, ".terragrunt-stack") + validateStackDir(t, path) + + // check that the stack was applied and .txt files got generated in the stack directory + var txtFiles []string + + err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && info.Name() == "test.txt" { + txtFiles = append(txtFiles, filePath) + } + + return nil + }) + + require.NoError(t, err) + assert.Len(t, txtFiles, 4) +} + +func TestStacksInputs(t *testing.T) { + t.Parallel() + + helpers.CleanupTerraformFolder(t, testFixtureStacksInputs) + tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStacksInputs) + rootPath := util.JoinPath(tmpEnvPath, testFixtureStacksInputs) + + helpers.RunTerragrunt(t, "terragrunt stack run plan --experiment stacks --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + + path := util.JoinPath(rootPath, ".terragrunt-stack") + validateStackDir(t, path) +} + +func TestStacksPlan(t *testing.T) { + t.Parallel() + + helpers.CleanupTerraformFolder(t, testFixtureStacksInputs) + tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStacksInputs) + rootPath := util.JoinPath(tmpEnvPath, testFixtureStacksInputs) + + stdout, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack run plan --experiment stacks --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + require.NoError(t, err) + + assert.Contains(t, stdout, "Plan: 1 to add, 0 to change, 0 to destroy") + assert.Contains(t, stdout, "local_file.file will be created") +} + +func TestStacksApply(t *testing.T) { + t.Parallel() + + helpers.CleanupTerraformFolder(t, testFixtureStacksInputs) + tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStacksInputs) + rootPath := util.JoinPath(tmpEnvPath, testFixtureStacksInputs) + + stdout, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack run apply --experiment stacks --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + require.NoError(t, err) + + assert.Contains(t, stdout, "Apply complete! Resources: 1 added, 0 changed, 0 destroyed") + assert.Contains(t, stdout, "local_file.file: Creation complete") +} + +func TestStacksDestroy(t *testing.T) { + t.Parallel() + + helpers.CleanupTerraformFolder(t, testFixtureStacksInputs) + tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStacksInputs) + rootPath := util.JoinPath(tmpEnvPath, testFixtureStacksInputs) + + helpers.RunTerragrunt(t, "terragrunt stack run apply --experiment stacks --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + + stdout, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack run destroy --experiment stacks --terragrunt-non-interactive --terragrunt-working-dir "+rootPath) + require.NoError(t, err) + + assert.Contains(t, stdout, "Plan: 0 to add, 0 to change, 1 to destroy") + assert.Contains(t, stdout, "local_file.file: Destroying...") +} + +// check if the stack directory is created and contains files. +func validateStackDir(t *testing.T, path string) { + t.Helper() + assert.DirExists(t, path) + + // check that path is not empty directory + entries, err := os.ReadDir(path) + require.NoError(t, err, "Failed to read directory contents") + + hasSubdirectories := false + for _, entry := range entries { + if entry.IsDir() { + hasSubdirectories = true + + break + } + } + + assert.True(t, hasSubdirectories, "The .terragrunt-stack directory should contain at least one subdirectory") }