diff --git a/cmd/terraform/generate/planfile.go b/cmd/terraform/generate/planfile.go index 51364ffa5b..37425f903a 100644 --- a/cmd/terraform/generate/planfile.go +++ b/cmd/terraform/generate/planfile.go @@ -53,6 +53,7 @@ var planfileCmd = &cobra.Command{ // Get flag values from Viper. stack := v.GetString("stack") file := v.GetString("file") + outputPath := v.GetString("output-path") format := v.GetString("format") processTemplates := v.GetBool("process-templates") processFunctions := v.GetBool("process-functions") @@ -82,6 +83,7 @@ var planfileCmd = &cobra.Command{ Component: component, Stack: stack, File: file, + OutputPath: outputPath, Format: format, ProcessTemplates: processTemplates, ProcessYamlFunctions: processFunctions, @@ -96,12 +98,14 @@ func init() { planfileParser = flags.NewStandardParser( flags.WithStringFlag("stack", "s", "", "Atmos stack (required)"), flags.WithStringFlag("file", "f", "", "Planfile name"), + flags.WithStringFlag("output-path", "o", "", "Output path for planfile using default naming ({stack}-{component}.planfile.{format})"), flags.WithStringFlag("format", "", "json", "Output format: json or yaml"), flags.WithBoolFlag("process-templates", "", true, "Enable Go template processing in Atmos stack manifests"), flags.WithBoolFlag("process-functions", "", true, "Enable YAML functions processing in Atmos stack manifests"), flags.WithStringSliceFlag("skip", "", []string{}, "Skip processing specific Atmos YAML functions"), flags.WithEnvVars("stack", "ATMOS_STACK"), flags.WithEnvVars("file", "ATMOS_FILE"), + flags.WithEnvVars("output-path", "ATMOS_OUTPUT_PATH"), flags.WithEnvVars("format", "ATMOS_FORMAT"), flags.WithEnvVars("process-templates", "ATMOS_PROCESS_TEMPLATES"), flags.WithEnvVars("process-functions", "ATMOS_PROCESS_FUNCTIONS"), @@ -111,6 +115,9 @@ func init() { // Register flags with the command. planfileParser.RegisterFlags(planfileCmd) + // Mark file and output-path as mutually exclusive. + planfileCmd.MarkFlagsMutuallyExclusive("file", "output-path") + // Bind flags to Viper for environment variable support. if err := planfileParser.BindToViper(viper.GetViper()); err != nil { panic(err) diff --git a/internal/exec/terraform_generate_planfile.go b/internal/exec/terraform_generate_planfile.go index 1082ad9aa1..5198bb166c 100644 --- a/internal/exec/terraform_generate_planfile.go +++ b/internal/exec/terraform_generate_planfile.go @@ -37,6 +37,7 @@ func ExecuteGeneratePlanfile(opts *PlanfileOptions, atmosConfig *schema.AtmosCon "component", opts.Component, "stack", opts.Stack, "file", opts.File, + "outputPath", opts.OutputPath, "format", opts.Format, "processTemplates", opts.ProcessTemplates, "processFunctions", opts.ProcessYamlFunctions, @@ -188,7 +189,13 @@ func ExecuteTerraformGeneratePlanfile( } // Resolve the planfile path based on options. If a custom file is specified, use that. Otherwise, use the default path. - planFilePath, err := resolvePlanfilePath(componentPath, options.Format, options.File, info, &atmosConfig) + pathOpts := planfilePathOptions{ + componentPath: componentPath, + format: options.Format, + customFile: options.File, + outputPath: options.OutputPath, + } + planFilePath, err := resolvePlanfilePath(pathOpts, info, &atmosConfig) if err != nil { return err } @@ -224,17 +231,39 @@ func validateComponent(component string) error { return nil } +// planfilePathOptions holds parameters for resolving planfile path. +type planfilePathOptions struct { + componentPath string + format string + customFile string + outputPath string +} + // resolvePlanfilePath determines the final path for the planfile based on options. -func resolvePlanfilePath(componentPath, format string, customFile string, info *schema.ConfigAndStacksInfo, atmosConfig *schema.AtmosConfiguration) (string, error) { +// It handles three modes: CustomFile set uses exact path (absolute or relative to component). +// OutputPath set uses default filename in specified directory. Neither uses atmos config default path. +func resolvePlanfilePath(opts planfilePathOptions, info *schema.ConfigAndStacksInfo, atmosConfig *schema.AtmosConfiguration) (string, error) { var planFilePath string - if customFile != "" { - if filepath.IsAbs(customFile) { - planFilePath = customFile + + switch { + case opts.customFile != "": + // Mode 1: Exact file path specified. + if filepath.IsAbs(opts.customFile) { + planFilePath = opts.customFile } else { - planFilePath = filepath.Join(componentPath, customFile) + planFilePath = filepath.Join(opts.componentPath, opts.customFile) } - } else { - planFilePath = fmt.Sprintf("%s.%s", constructTerraformComponentPlanfilePath(atmosConfig, info), format) + case opts.outputPath != "": + // Mode 2: Output directory specified, use default filename. + defaultFilename := fmt.Sprintf("%s-%s.planfile.%s", info.StackFromArg, info.ComponentFromArg, opts.format) + if filepath.IsAbs(opts.outputPath) { + planFilePath = filepath.Join(opts.outputPath, defaultFilename) + } else { + planFilePath = filepath.Join(opts.componentPath, opts.outputPath, defaultFilename) + } + default: + // Mode 3: Use atmos config default path. + planFilePath = fmt.Sprintf("%s.%s", constructTerraformComponentPlanfilePath(atmosConfig, info), opts.format) } err := u.EnsureDir(planFilePath) diff --git a/internal/exec/terraform_generate_planfile_test.go b/internal/exec/terraform_generate_planfile_test.go index bc919eef9c..e458c57336 100644 --- a/internal/exec/terraform_generate_planfile_test.go +++ b/internal/exec/terraform_generate_planfile_test.go @@ -106,6 +106,12 @@ func TestExecuteTerraformGeneratePlanfile(t *testing.T) { err = os.Remove(fmt.Sprintf("%s/planfiles/new-planfile.yaml", componentPath)) assert.NoError(t, err) + + err = os.Remove(fmt.Sprintf("%s/output/%s-%s.planfile.json", componentPath, stack, component)) + assert.NoError(t, err) + + err = os.RemoveAll(filepath.Join(componentPath, "output")) + assert.NoError(t, err) }() options := PlanfileOptions{ @@ -113,6 +119,7 @@ func TestExecuteTerraformGeneratePlanfile(t *testing.T) { Stack: stack, Format: "json", File: "", + OutputPath: "", ProcessTemplates: true, ProcessYamlFunctions: true, Skip: nil, @@ -174,6 +181,24 @@ func TestExecuteTerraformGeneratePlanfile(t *testing.T) { } else if err != nil { t.Errorf("Error checking file: %v", err) } + + // Test --output-path option: generates planfile in specified directory with default filename. + options.Format = "json" + options.File = "" + options.OutputPath = "output" + err = ExecuteTerraformGeneratePlanfile( + &options, + &info, + ) + assert.NoError(t, err) + + // Verify the planfile was created with default naming in the output directory. + filePath = fmt.Sprintf("%s/output/%s-%s.planfile.json", componentPath, stack, component) + if _, err = os.Stat(filePath); os.IsNotExist(err) { + t.Errorf("Generated planfile does not exist: %s", filePath) + } else if err != nil { + t.Errorf("Error checking file: %v", err) + } } func TestExecuteTerraformGeneratePlanfileErrors(t *testing.T) { @@ -333,3 +358,66 @@ func TestPlanfileValidateComponent(t *testing.T) { }) } } + +// TestResolvePlanfilePath tests the resolvePlanfilePath function with different modes. +func TestResolvePlanfilePath(t *testing.T) { + // Create a temporary directory for testing. + tmpDir := t.TempDir() + componentPath := tmpDir + info := &schema.ConfigAndStacksInfo{ + StackFromArg: "dev-us-east-1", + ComponentFromArg: "vpc", + } + atmosConfig := &schema.AtmosConfiguration{} + + testCases := []struct { + name string + customFile string + outputPath string + format string + expectedPath string + }{ + { + name: "Mode 1: Exact file path (relative)", + customFile: "custom-planfile.json", + outputPath: "", + format: "json", + expectedPath: filepath.Join(componentPath, "custom-planfile.json"), + }, + { + name: "Mode 1: Exact file path (absolute)", + customFile: filepath.Join(tmpDir, "absolute", "path.json"), + outputPath: "", + format: "json", + expectedPath: filepath.Join(tmpDir, "absolute", "path.json"), + }, + { + name: "Mode 2: Output directory with default filename (relative)", + customFile: "", + outputPath: "planfiles", + format: "json", + expectedPath: filepath.Join(componentPath, "planfiles", "dev-us-east-1-vpc.planfile.json"), + }, + { + name: "Mode 2: Output directory with default filename (absolute)", + customFile: "", + outputPath: filepath.Join(tmpDir, "output"), + format: "yaml", + expectedPath: filepath.Join(tmpDir, "output", "dev-us-east-1-vpc.planfile.yaml"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + opts := planfilePathOptions{ + componentPath: componentPath, + format: tc.format, + customFile: tc.customFile, + outputPath: tc.outputPath, + } + result, err := resolvePlanfilePath(opts, info, atmosConfig) + assert.NoError(t, err) + assert.Equal(t, tc.expectedPath, result) + }) + } +} diff --git a/pkg/terraform/options.go b/pkg/terraform/options.go index d5d1313f21..272d24d63d 100644 --- a/pkg/terraform/options.go +++ b/pkg/terraform/options.go @@ -50,6 +50,7 @@ type PlanfileOptions struct { Stack string Format string File string + OutputPath string // Output path for planfile using default naming convention. ProcessTemplates bool ProcessYamlFunctions bool Skip []string diff --git a/website/blog/2025-12-16-planfile-output-path-flag.mdx b/website/blog/2025-12-16-planfile-output-path-flag.mdx new file mode 100644 index 0000000000..be61274a56 --- /dev/null +++ b/website/blog/2025-12-16-planfile-output-path-flag.mdx @@ -0,0 +1,59 @@ +--- +slug: planfile-output-path-flag +title: 'New --output-path Flag for Planfile Generation' +authors: + - osterman +tags: + - enhancement + - dx +date: 2025-12-16T00:00:00.000Z +--- + +The `terraform generate planfile` command now supports an `--output-path` flag that lets you specify an output directory while keeping the default filename convention. + + + +## What Changed + +Previously, you had two options for planfile output: +- Default location in the component directory with auto-generated filename +- Exact file path with `--file` flag + +Now there's a third option: specify just the output directory and let Atmos generate the filename automatically. + +## Using --output-path + +```shell +# Generate planfile in a subdirectory with default naming +atmos terraform generate planfile vpc -s plat-ue2-dev --output-path=planfiles + +# The planfile is created as: planfiles/plat-ue2-dev-vpc.planfile.json + +# Also supports absolute paths +atmos terraform generate planfile vpc -s plat-ue2-dev -o /tmp/planfiles +``` + +The default filename format is `{stack}-{component}.planfile.{format}`. + +## Comparison with --file + +| Flag | Use Case | +|------|----------| +| `--file` | Specify exact output path including filename | +| `--output-path` | Specify directory, use default filename | + +The flags are mutually exclusive - use one or the other, not both. + +## Why This Matters + +This is particularly useful for CI/CD pipelines where you want planfiles organized in a specific directory but don't want to construct filenames manually: + +```shell +# Collect all planfiles in one location for review +atmos terraform generate planfile vpc -s plat-ue2-dev --output-path=artifacts/planfiles +atmos terraform generate planfile rds -s plat-ue2-dev --output-path=artifacts/planfiles +``` + +## Documentation + +See the [terraform generate planfile](/cli/commands/terraform/generate-planfile) documentation for complete usage details. diff --git a/website/docs/cli/commands/terraform/generate/planfile.mdx b/website/docs/cli/commands/terraform/generate/planfile.mdx index 7c4cc096ad..388c250f9f 100644 --- a/website/docs/cli/commands/terraform/generate/planfile.mdx +++ b/website/docs/cli/commands/terraform/generate/planfile.mdx @@ -38,6 +38,8 @@ atmos terraform generate planfile component1 -s plat-ue2-prod --format=yaml atmos terraform generate planfile -s --file=planfile.json atmos terraform generate planfile -s --format=yaml --file=planfiles/planfile.yaml atmos terraform generate planfile -s --file=/Users/me/Documents/atmos/infra/planfile.json +atmos terraform generate planfile -s --output-path=planfiles +atmos terraform generate planfile -s -o /tmp/planfiles ``` ## Arguments @@ -90,6 +92,26 @@ atmos terraform generate planfile -s --file=/Users/me/Docume ``` +
`--output-path` (alias `-o`) (optional)
+
+ Output path where the planfile will be generated using the default naming convention + (`{stack}-{component}.planfile.{format}`). + + This flag is mutually exclusive with `--file`. Use `--output-path` when you want to specify + a custom output location but keep the default filename. + + Supports absolute and relative paths. Relative paths are resolved from the component directory. + Directories are created automatically if they don't exist. + + ```shell + # Generate in a relative subdirectory + atmos terraform generate planfile -s --output-path=planfiles + + # Generate in an absolute directory + atmos terraform generate planfile -s --output-path=/tmp/planfiles + ``` +
+
`--process-templates` (optional)
Enable/disable Go template processing in Atmos stack manifests when executing terraform commands.