Skip to content

Commit 770e03b

Browse files
ostermanclaudeautofix-ci[bot]aknysh
authored
feat: Add multiple terraform output formats for CI integration (#1885)
* refactor: Move terraform output execution to pkg/terraform/output with DI pattern ## What Changed - Moved all terraform output execution logic from `internal/exec/` to new `pkg/terraform/output/` package - Implemented dependency injection pattern using `ComponentDescriber` interface to break circular dependencies - Created backward-compatible wrappers in `internal/exec/` to maintain API compatibility - Achieved <30 cognitive complexity per function through strategic refactoring - Added 39 comprehensive unit tests covering executor and configuration extraction ## Key Improvements - **No circular dependencies**: `pkg/terraform/output/` defines interfaces, `internal/exec/` implements them - **Better separation of concerns**: terraform output logic isolated in focused package - **Testability**: All major functions tested with mocks, 89 total tests in package - **Maintainability**: Reduced `internal/exec/terraform_output_utils.go` from 761 to focused helper ## Files Created - `pkg/terraform/output/executor.go` - Core orchestration engine - `pkg/terraform/output/config.go` - Configuration extraction - `pkg/terraform/output/workspace.go` - Workspace management - `pkg/terraform/output/backend.go` - Backend generation - `pkg/terraform/output/environment.go` - Environment setup - `pkg/terraform/output/platform_*.go` - Platform-specific utilities - `pkg/terraform/output/*_test.go` - Comprehensive unit tests - `internal/exec/component_describer_adapter.go` - Dependency injection adapter ## Files Deleted - `internal/exec/terraform_output_utils_*.go` (platform-specific files) - Integration tests (migrated to unit tests in new package) ## Verification - ✅ `make build` passes - ✅ `make lint` passes (0 issues) - ✅ All 89 tests pass - ✅ No circular dependencies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 <[email protected]> * [autofix.ci] apply automated fixes * fix: Address code review feedback for spinner and workspace - Replace fmt.Fprintln/fmt.Println with ui.Writeln per coding guidelines - Add debug log when workspace creation fails before attempting select - Fixes CodeQL false positive about sensitive data logging 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * fix: Properly differentiate workspace errors in terraform output - Add isWorkspaceExistsError helper to check for "already exists" errors - Fail fast on unexpected errors (network, permission) instead of silently falling through to workspace select - Add tests for both "already exists" and unexpected error cases - Fix godot linter issue in comment 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * fix: Allow multiple Output calls in test for Windows retry logic The retryOnWindows function retries failed terraform output calls up to 3 times on Windows. Update the mock expectation to use AnyTimes() to prevent flaky test failures on Windows CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * test: Add unit tests for backend and environment setup in terraform output Add comprehensive unit tests for previously untested code in the terraform output package: - backend_test.go: Tests for generateBackendConfig, generateProviderOverrides, GenerateBackendIfNeeded, and GenerateProvidersIfNeeded functions - environment_test.go: Tests for SetupEnvironment including prohibited variable filtering, AWS auth context merging, and component env overrides These tests increase package coverage from ~70% to ~80%. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * docs: Add multiple terraform output formats to roadmap Add milestone documenting support for exporting terraform outputs in multiple formats (JSON, YAML, HCL, env, dotenv, bash, CSV, TSV) with options for uppercase keys and nested value flattening. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * add tests * address comments * address comments --------- Co-authored-by: Claude Haiku 4.5 <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Andriy Knysh <[email protected]> Co-authored-by: aknysh <[email protected]>
1 parent 97d1641 commit 770e03b

40 files changed

+6377
-1008
lines changed

CLAUDE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@ This repository uses git worktrees for parallel development. When working in a w
1919

2020
**For Task agents:** When searching for files, always use the current working directory (`.`) or relative paths. Never construct absolute paths that might escape the worktree.
2121

22+
## Concurrent Sessions (MANDATORY)
23+
24+
Multiple Claude sessions may be working on the same branch or worktree simultaneously. To avoid destroying other sessions' work:
25+
26+
- **NEVER delete, reset, or discard files you didn't create** - Other sessions may have created them
27+
- **NEVER run `git reset`, `git checkout --`, or `git clean`** without explicit user approval
28+
- **ALWAYS ask the user before removing untracked files** - They may be work-in-progress from another session
29+
- **When you see unfamiliar files**, assume another session created them - ask the user what to do
30+
- **If pre-commit hooks fail due to files you didn't touch**, ask the user how to proceed rather than trying to fix or remove them
31+
32+
**Why this matters:** The user may have multiple Claude sessions working in parallel on different aspects of a feature. Deleting “unknown” files destroys that work.
33+
2234
## Essential Commands
2335

2436
```bash

cmd/terraform/output.go

Lines changed: 170 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,195 @@
11
package terraform
22

33
import (
4+
"slices"
5+
"strings"
6+
47
"github.com/spf13/cobra"
8+
"github.com/spf13/viper"
59

610
"github.com/cloudposse/atmos/cmd/internal"
11+
errUtils "github.com/cloudposse/atmos/errors"
12+
exec "github.com/cloudposse/atmos/internal/exec"
13+
cfg "github.com/cloudposse/atmos/pkg/config"
14+
"github.com/cloudposse/atmos/pkg/data"
15+
"github.com/cloudposse/atmos/pkg/flags"
16+
"github.com/cloudposse/atmos/pkg/flags/compat"
17+
"github.com/cloudposse/atmos/pkg/perf"
18+
"github.com/cloudposse/atmos/pkg/schema"
19+
tfoutput "github.com/cloudposse/atmos/pkg/terraform/output"
720
)
821

22+
// outputParser handles flag parsing for output command.
23+
var outputParser *flags.StandardParser
24+
925
// outputCmd represents the terraform output command.
1026
var outputCmd = &cobra.Command{
1127
Use: "output",
1228
Short: "Show output values from your root module",
13-
Long: `Read an output variable from the state file.
29+
Long: `Read output variables from the state file.
30+
31+
When --format is specified, retrieves all outputs and formats them in the specified format.
32+
Without --format, passes through to native terraform/tofu output command.
1433
1534
For complete Terraform/OpenTofu documentation, see:
1635
https://developer.hashicorp.com/terraform/cli/commands/output
1736
https://opentofu.org/docs/cli/commands/output`,
1837
RunE: func(cmd *cobra.Command, args []string) error {
19-
return terraformRun(terraformCmd, cmd, args)
38+
v := viper.GetViper()
39+
if err := terraformParser.BindFlagsToViper(cmd, v); err != nil {
40+
return err
41+
}
42+
if err := outputParser.BindFlagsToViper(cmd, v); err != nil {
43+
return err
44+
}
45+
format := v.GetString("format")
46+
if format == "" {
47+
return terraformRun(terraformCmd, cmd, args)
48+
}
49+
return outputRunWithFormat(cmd, args, format)
2050
},
2151
}
2252

53+
// outputRunWithFormat executes terraform output with atmos formatting.
54+
func outputRunWithFormat(cmd *cobra.Command, args []string, format string) error {
55+
defer perf.Track(nil, "terraform.outputRunWithFormat")()
56+
57+
if err := validateOutputFormat(format); err != nil {
58+
return err
59+
}
60+
info, atmosConfig, err := prepareOutputContext(cmd, args)
61+
if err != nil {
62+
return err
63+
}
64+
return executeOutputWithFormat(atmosConfig, info, format)
65+
}
66+
67+
// validateOutputFormat checks if the format is supported.
68+
func validateOutputFormat(format string) error {
69+
if !slices.Contains(tfoutput.SupportedFormats, format) {
70+
return errUtils.Build(errUtils.ErrInvalidArgumentError).
71+
WithExplanationf("Invalid --format value %q.", format).
72+
WithHintf("Supported formats: %s.", strings.Join(tfoutput.SupportedFormats, ", ")).
73+
Err()
74+
}
75+
return nil
76+
}
77+
78+
// prepareOutputContext validates config and prepares component info.
79+
func prepareOutputContext(cmd *cobra.Command, args []string) (*schema.ConfigAndStacksInfo, *schema.AtmosConfiguration, error) {
80+
if err := internal.ValidateAtmosConfig(); err != nil {
81+
return nil, nil, err
82+
}
83+
separatedArgs := compat.GetSeparated()
84+
argsWithSubCommand := append([]string{"output"}, args...)
85+
info, err := exec.ProcessCommandLineArgs(cfg.TerraformComponentType, terraformCmd, argsWithSubCommand, separatedArgs)
86+
if err != nil {
87+
return nil, nil, err
88+
}
89+
if err := resolveAndPromptForArgs(&info, cmd); err != nil {
90+
return nil, nil, err
91+
}
92+
v := viper.GetViper()
93+
globalFlags := flags.ParseGlobalFlags(cmd, v)
94+
configAndStacksInfo := schema.ConfigAndStacksInfo{
95+
AtmosBasePath: globalFlags.BasePath,
96+
AtmosConfigFilesFromArg: globalFlags.Config,
97+
AtmosConfigDirsFromArg: globalFlags.ConfigPath,
98+
ProfilesFromArg: globalFlags.Profile,
99+
ComponentFromArg: info.ComponentFromArg,
100+
Stack: info.Stack,
101+
}
102+
atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true)
103+
if err != nil {
104+
return nil, nil, errUtils.Build(errUtils.ErrInitializeCLIConfig).WithCause(err).Err()
105+
}
106+
return &info, &atmosConfig, nil
107+
}
108+
109+
// executeOutputWithFormat retrieves and formats terraform outputs.
110+
func executeOutputWithFormat(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, format string) error {
111+
v := viper.GetViper()
112+
skipInit := v.GetBool("skip-init")
113+
outputFile := v.GetString("output-file")
114+
uppercase := v.GetBool("uppercase")
115+
flatten := v.GetBool("flatten")
116+
117+
outputs, err := tfoutput.GetComponentOutputs(atmosConfig, info.ComponentFromArg, info.Stack, skipInit)
118+
if err != nil {
119+
return errUtils.Build(errUtils.ErrTerraformOutputFailed).
120+
WithCause(err).
121+
WithExplanationf("Failed to get terraform outputs for component %q in stack %q.", info.ComponentFromArg, info.Stack).
122+
Err()
123+
}
124+
125+
// Build format options.
126+
opts := tfoutput.FormatOptions{
127+
Uppercase: uppercase,
128+
Flatten: flatten,
129+
}
130+
131+
// Check if a specific output name was requested (in AdditionalArgsAndFlags).
132+
outputName := extractOutputName(info.AdditionalArgsAndFlags)
133+
var formatted string
134+
if outputName != "" {
135+
formatted, err = formatSingleOutput(outputs, outputName, format, opts)
136+
} else {
137+
formatted, err = tfoutput.FormatOutputsWithOptions(outputs, tfoutput.Format(format), opts)
138+
}
139+
if err != nil {
140+
return err
141+
}
142+
143+
if outputFile != "" {
144+
return tfoutput.WriteToFile(outputFile, formatted)
145+
}
146+
return data.Write(formatted)
147+
}
148+
149+
// extractOutputName extracts the output name from additional args.
150+
// Output name is a positional arg that doesn't start with "-".
151+
func extractOutputName(args []string) string {
152+
for _, arg := range args {
153+
if !strings.HasPrefix(arg, "-") {
154+
return arg
155+
}
156+
}
157+
return ""
158+
}
159+
160+
// formatSingleOutput formats a single output value.
161+
func formatSingleOutput(outputs map[string]any, outputName, format string, opts tfoutput.FormatOptions) (string, error) {
162+
value, exists := outputs[outputName]
163+
if !exists {
164+
return "", errUtils.Build(errUtils.ErrTerraformOutputFailed).
165+
WithExplanationf("Output %q not found.", outputName).
166+
WithHint("Use 'atmos terraform output <component> -s <stack>' without an output name to see all available outputs.").
167+
Err()
168+
}
169+
return tfoutput.FormatSingleValueWithOptions(outputName, value, tfoutput.Format(format), opts)
170+
}
171+
23172
func init() {
24-
// Register completions for outputCmd.
173+
outputParser = flags.NewStandardParser(
174+
flags.WithStringFlag("format", "f", "", "Output format: json, yaml, hcl, env, dotenv, bash, csv, tsv"),
175+
flags.WithStringFlag("output-file", "o", "", "Write output to file instead of stdout"),
176+
flags.WithBoolFlag("uppercase", "u", false, "Convert keys to uppercase (useful for env vars)"),
177+
flags.WithBoolFlag("flatten", "", false, "Flatten nested maps into key_subkey format"),
178+
flags.WithEnvVars("format", "ATMOS_TERRAFORM_OUTPUT_FORMAT"),
179+
flags.WithEnvVars("output-file", "ATMOS_TERRAFORM_OUTPUT_FILE"),
180+
flags.WithEnvVars("uppercase", "ATMOS_TERRAFORM_OUTPUT_UPPERCASE"),
181+
flags.WithEnvVars("flatten", "ATMOS_TERRAFORM_OUTPUT_FLATTEN"),
182+
)
183+
outputParser.RegisterFlags(outputCmd)
184+
if err := outputParser.BindToViper(viper.GetViper()); err != nil {
185+
panic(err)
186+
}
187+
if err := outputCmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
188+
return tfoutput.SupportedFormats, cobra.ShellCompDirectiveNoFileComp
189+
}); err != nil {
190+
_ = err
191+
}
25192
RegisterTerraformCompletions(outputCmd)
26-
27-
// Register compat flags for this subcommand.
28193
internal.RegisterCommandCompatFlags("terraform", "output", OutputCompatFlags())
29-
30-
// Attach to parent terraform command.
31194
terraformCmd.AddCommand(outputCmd)
32195
}

0 commit comments

Comments
 (0)