Skip to content
Closed
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
7 changes: 7 additions & 0 deletions cmd/terraform/generate/planfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -82,6 +83,7 @@ var planfileCmd = &cobra.Command{
Component: component,
Stack: stack,
File: file,
OutputPath: outputPath,
Format: format,
ProcessTemplates: processTemplates,
ProcessYamlFunctions: processFunctions,
Expand All @@ -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"),
Expand All @@ -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)
Expand Down
45 changes: 37 additions & 8 deletions internal/exec/terraform_generate_planfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
88 changes: 88 additions & 0 deletions internal/exec/terraform_generate_planfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,20 @@ 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{
Component: component,
Stack: stack,
Format: "json",
File: "",
OutputPath: "",
ProcessTemplates: true,
ProcessYamlFunctions: true,
Skip: nil,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
})
}
}
1 change: 1 addition & 0 deletions pkg/terraform/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions website/blog/2025-12-16-planfile-output-path-flag.mdx
Original file line number Diff line number Diff line change
@@ -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.

<!--truncate-->

## 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.
22 changes: 22 additions & 0 deletions website/docs/cli/commands/terraform/generate/planfile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ atmos terraform generate planfile component1 -s plat-ue2-prod --format=yaml
atmos terraform generate planfile <component> -s <stack> --file=planfile.json
atmos terraform generate planfile <component> -s <stack> --format=yaml --file=planfiles/planfile.yaml
atmos terraform generate planfile <component> -s <stack> --file=/Users/me/Documents/atmos/infra/planfile.json
atmos terraform generate planfile <component> -s <stack> --output-path=planfiles
atmos terraform generate planfile <component> -s <stack> -o /tmp/planfiles
```

## Arguments
Expand Down Expand Up @@ -90,6 +92,26 @@ atmos terraform generate planfile <component> -s <stack> --file=/Users/me/Docume
```
</dd>

<dt>`--output-path` <em>(alias `-o`)</em> <em>(optional)</em></dt>
<dd>
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 <component> -s <stack> --output-path=planfiles

# Generate in an absolute directory
atmos terraform generate planfile <component> -s <stack> --output-path=/tmp/planfiles
```
</dd>

<dt>`--process-templates` <em>(optional)</em></dt>
<dd>
Enable/disable Go template processing in Atmos stack manifests when executing terraform commands.
Expand Down
Loading