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
2 changes: 2 additions & 0 deletions pkg/env/shims.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type Shims struct {
LookupEnv func(string) (string, bool)
Environ func() []string
Getenv func(string) string
Command func(name string, arg ...string) *exec.Cmd
}

// =============================================================================
Expand Down Expand Up @@ -66,5 +67,6 @@ func NewShims() *Shims {
LookupEnv: os.LookupEnv,
Environ: os.Environ,
Getenv: os.Getenv,
Command: exec.Command,
}
}
67 changes: 67 additions & 0 deletions pkg/env/terraform_env.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package env

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
Expand All @@ -25,6 +26,16 @@ type TerraformEnvPrinter struct {
BaseEnvPrinter
}

// TerraformOutput a struct that mimics the terraform output JSON
type TerraformOutput struct {
Value interface{} `json:"value"`
Type interface{} `json:"type"`
Sensitive bool `json:"sensitive"`
}

// TerraformOutputs a map of output names to TerraformOutput
type TerraformOutputs map[string]TerraformOutput

// =============================================================================
// Constructor
// =============================================================================
Expand Down Expand Up @@ -129,6 +140,15 @@ func (e *TerraformEnvPrinter) GetEnvVars() (map[string]string, error) {
envVars["TF_VAR_os_type"] = "unix"
}

outputVars, err := e.getTerraformOutputs(projectPath)
if err != nil {
return nil, fmt.Errorf("error deriving output vars: %w", err)
}

for k, v := range outputVars {
envVars[k] = v
}

return envVars, nil
}

Expand All @@ -150,6 +170,53 @@ func (e *TerraformEnvPrinter) Print() error {
// Private Methods
// =============================================================================

// getTerraformOutputs retrieves Terraform outputs from the specified project path.
// It executes 'terraform output -json' and converts the outputs into environment variables.
// Each output is prefixed with 'TF_VAR_' to make it available as a Terraform variable.
// Returns a map of environment variable names to their formatted values, or an error if the command fails.
func (e *TerraformEnvPrinter) getTerraformOutputs(projectPath string) (map[string]string, error) {
cmd := e.shims.Command("terraform", "-chdir="+projectPath, "output", "-json")
Copy link
Contributor

@rmvangun rmvangun May 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For command/exec specifically, the shell package is used when executing commands. This approach abstracts execution so that in the future, we could execute commands against other types of shells--docker exec, SSH, etc.

You can find examples of this elsewhere, but it will end up being something like shell.ExecSilent("terraform", "-chdir=...")

outputJSON, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to run terraform output: %w", err)
}
var outputs TerraformOutputs
if err := json.Unmarshal(outputJSON, &outputs); err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be shimmed for test coverage

return nil, fmt.Errorf("failed to parse terraform output: %w", err)
}

envVars := make(map[string]string)
for name, output := range outputs {
envVars["TF_VAR_"+name] = formatTerraformValue(output.Value)
}
return envVars, nil
}

// formatTerraformValue converts a Terraform output value to a string representation.
// It handles different value types:
// - Strings are returned as-is
// - Numbers and booleans are converted to strings
// - Lists are joined with commas
// - Complex types are JSON marshaled
// Returns a string representation of the value suitable for environment variables.
func formatTerraformValue(val interface{}) string {
switch v := val.(type) {
case string:
return v
case float64, int, bool:
return fmt.Sprintf("%v", v)
case []interface{}:
parts := make([]string, len(v))
for i, elem := range v {
parts[i] = fmt.Sprintf("%v", elem)
}
return strings.Join(parts, ",")
default:
b, _ := json.Marshal(v)
return string(b)
}
}

// generateBackendOverrideTf creates the backend_override.tf file for the project by determining
// the backend type and writing the appropriate configuration to the file.
func (e *TerraformEnvPrinter) generateBackendOverrideTf() error {
Expand Down
155 changes: 155 additions & 0 deletions pkg/env/terraform_env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package env
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"sort"
Expand Down Expand Up @@ -1291,3 +1292,157 @@ func TestTerraformEnv_processBackendConfig(t *testing.T) {
}
})
}

func TestTerraformEnv_getTerraformOutputs(t *testing.T) {
setup := func(t *testing.T) (*TerraformEnvPrinter, *Mocks) {
t.Helper()
mocks := setupTerraformEnvMocks(t)
printer := NewTerraformEnvPrinter(mocks.Injector)
printer.shims = mocks.Shims
if err := printer.Initialize(); err != nil {
t.Fatalf("Failed to initialize printer: %v", err)
}
return printer, mocks
}

t.Run("Success", func(t *testing.T) {
// Given a TerraformEnvPrinter with mock terraform output
printer, mocks := setup(t)
projectPath := "project/path"

// Mock terraform output command
mocks.Shims.Command = func(name string, arg ...string) *exec.Cmd {
cmd := exec.Command("echo", `{
"instance_id": {"value": "i-123456", "type": "string"},
"port": {"value": 8080, "type": "number"},
"tags": {"value": ["prod", "web"], "type": "list"}
}`)
return cmd
}

// When getTerraformOutputs is called
outputs, err := printer.getTerraformOutputs(projectPath)

// Then no error should occur and outputs should be correctly formatted
if err != nil {
t.Errorf("Expected no error, got %v", err)
}

expectedOutputs := map[string]string{
"TF_VAR_instance_id": "i-123456",
"TF_VAR_port": "8080",
"TF_VAR_tags": "prod,web",
}

if !reflect.DeepEqual(outputs, expectedOutputs) {
t.Errorf("Expected outputs %v, got %v", expectedOutputs, outputs)
}
})

t.Run("CommandError", func(t *testing.T) {
// Given a TerraformEnvPrinter with failing terraform command
printer, mocks := setup(t)
projectPath := "project/path"

// Mock failing terraform command
mocks.Shims.Command = func(name string, arg ...string) *exec.Cmd {
cmd := exec.Command("false")
return cmd
}

// When getTerraformOutputs is called
_, err := printer.getTerraformOutputs(projectPath)

// Then an error should be returned
if err == nil {
t.Errorf("Expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to run terraform output") {
t.Errorf("Expected error message to contain 'failed to run terraform output', got %v", err)
}
})

t.Run("InvalidJSON", func(t *testing.T) {
// Given a TerraformEnvPrinter with invalid JSON output
printer, mocks := setup(t)
projectPath := "project/path"

// Mock terraform command with invalid JSON
mocks.Shims.Command = func(name string, arg ...string) *exec.Cmd {
cmd := exec.Command("echo", `invalid json`)
return cmd
}

// When getTerraformOutputs is called
_, err := printer.getTerraformOutputs(projectPath)

// Then an error should be returned
if err == nil {
t.Errorf("Expected error, got nil")
}
if !strings.Contains(err.Error(), "failed to parse terraform output") {
t.Errorf("Expected error message to contain 'failed to parse terraform output', got %v", err)
}
})
}

func TestTerraformEnv_formatTerraformValue(t *testing.T) {
tests := []struct {
name string
input interface{}
expected string
}{
{
name: "String value",
input: "test-string",
expected: "test-string",
},
{
name: "Integer value",
input: 42,
expected: "42",
},
{
name: "Float value",
input: 3.14,
expected: "3.14",
},
{
name: "Boolean value",
input: true,
expected: "true",
},
{
name: "String array",
input: []interface{}{"item1", "item2", "item3"},
expected: "item1,item2,item3",
},
{
name: "Mixed array",
input: []interface{}{"item1", 42, true},
expected: "item1,42,true",
},
{
name: "Map value",
input: map[string]interface{}{"key": "value"},
expected: `{"key":"value"}`,
},
{
name: "Nil value",
input: nil,
expected: "null",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// When formatTerraformValue is called
result := formatTerraformValue(tt.input)

// Then the result should match the expected output
if result != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, result)
}
})
}
}
Loading