Skip to content
Draft
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 cli/commands/stack/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const (
JSONFormatFlagName = "json"
RawFormatFlagName = "raw"
NoStackValidate = "no-stack-validate"
JSONValuesFlagName = "json-values"

generateCommandName = "generate"
runCommandName = "run"
Expand Down Expand Up @@ -83,6 +84,12 @@ func defaultFlags(l log.Logger, opts *options.TerragruntOptions, prefix flags.Pr
Hidden: true,
Usage: "Disable automatic stack validation after generation.",
}),
flags.NewFlag(&cli.BoolFlag{
Name: JSONValuesFlagName,
EnvVars: tgPrefix.EnvVars(JSONValuesFlagName),
Destination: &opts.StackValuesJSON,
Usage: "Write stack values files in JSON format instead of HCL.",
}),
}

return append(run.NewFlags(l, opts, nil), flags...)
Expand Down
2 changes: 1 addition & 1 deletion config/config_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,7 @@ func ParseTerragruntConfig(ctx *ParsingContext, l log.Logger, configPath string,
}

// check if file is a values file, decode as values file
if strings.HasSuffix(targetConfig, valuesFile) {
if strings.HasSuffix(targetConfig, valuesFile) || strings.HasSuffix(targetConfig, valuesFileJSON) {
unitValues, err := ReadValues(ctx.Context, l, ctx.TerragruntOptions, filepath.Dir(targetConfig))
if err != nil {
return cty.NilVal, errors.New(err)
Expand Down
115 changes: 98 additions & 17 deletions config/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -33,6 +34,7 @@ import (
const (
StackDir = ".terragrunt-stack"
valuesFile = "terragrunt.values.hcl"
valuesFileJSON = "terragrunt.values.json"
manifestName = ".terragrunt-stack-manifest"
defaultStackFile = "terragrunt.stack.hcl"
unitDirPerm = 0755
Expand Down Expand Up @@ -581,7 +583,7 @@ func processComponent(ctx context.Context, l log.Logger, opts *options.Terragrun
}

// generate values file
if err := writeValues(l, cmp.values, dest); err != nil {
if err := writeValues(l, opts, cmp.values, dest); err != nil {
return errors.Errorf("failed to write values %v %w", cmp.name, err)
}

Expand Down Expand Up @@ -771,8 +773,8 @@ func ParseStackConfig(l log.Logger, parser *ParsingContext, opts *options.Terrag
return stackConfig, nil
}

// writeValues generates and writes values to a terragrunt.values.hcl file in the specified directory.
func writeValues(l log.Logger, values *cty.Value, directory string) error {
// writeValues generates and writes values to a terragrunt.values.hcl or terragrunt.values.json file in the specified directory.
func writeValues(l log.Logger, opts *options.TerragruntOptions, values *cty.Value, directory string) error {
if values == nil {
l.Debugf("No values to write in %s", directory)
return nil
Expand All @@ -786,8 +788,57 @@ func writeValues(l log.Logger, values *cty.Value, directory string) error {
return errors.Errorf("failed to create directory %s: %w", directory, err)
}

l.Debugf("Writing values file in %s", directory)
filePath := filepath.Join(directory, valuesFile)
var (
fileName string
fileContent []byte
err error
)

if opts.StackValuesJSON {
fileName = valuesFileJSON
fileContent, err = renderValuesAsJSON(values)
} else {
fileName = valuesFile
fileContent, err = renderValuesAsHCL(values)
}

if err != nil {
return errors.Errorf("failed to generate values content: %w", err)
}

filePath := filepath.Join(directory, fileName)

format := "HCL"
if opts.StackValuesJSON {
format = "JSON"
}

l.Debugf("Writing values file in %s format to %s", format, filePath)

if err := os.WriteFile(filePath, fileContent, valueFilePerm); err != nil {
return errors.Errorf("failed to write values file %s: %w", filePath, err)
}

return nil
}

// getSortedKeys extracts and returns sorted keys from any map[string]T
func getSortedKeys[T any](m map[string]T) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}

sort.Strings(keys)

return keys
}

// renderValuesAsHCL renders values in HCL format
func renderValuesAsHCL(values *cty.Value) ([]byte, error) {
if values == nil {
return nil, errors.Errorf("values cannot be nil")
}

file := hclwrite.NewEmptyFile()
body := file.Body()
Expand All @@ -800,35 +851,65 @@ func writeValues(l log.Logger, values *cty.Value, directory string) error {

// Sort keys for deterministic output
valueMap := values.AsValueMap()

keys := make([]string, 0, len(valueMap))
for key := range valueMap {
keys = append(keys, key)
if len(valueMap) == 0 {
return file.Bytes(), nil
}

// Sort keys alphabetically
sort.Strings(keys)
keys := getSortedKeys(valueMap)

for _, key := range keys {
body.SetAttributeValue(key, valueMap[key])
}

if err := os.WriteFile(filePath, file.Bytes(), valueFilePerm); err != nil {
return errors.Errorf("failed to write values file %s: %w", filePath, err)
return file.Bytes(), nil
}

// renderValuesAsJSON renders values in JSON format
func renderValuesAsJSON(values *cty.Value) ([]byte, error) {
goValues, err := ctyhelper.ParseCtyValueToMap(*values)
if err != nil {
return nil, errors.Errorf("failed to convert cty values to Go values: %w", err)
}

return nil
// Create a JSON object with a comment
output := map[string]any{
"_comment": "Auto-generated by the terragrunt.stack.hcl file by Terragrunt. Do not edit manually",
}

// Sort keys for deterministic output (consistent with HCL format)
keys := getSortedKeys(goValues)

// Add all the values in sorted order
for _, key := range keys {
output[key] = goValues[key]
}

jsonBytes, err := json.MarshalIndent(output, "", " ")
if err != nil {
return nil, errors.Errorf("failed to marshal values to JSON: %w", err)
}

return jsonBytes, nil
}

// ReadValues reads values from the terragrunt.values.hcl file in the specified directory.
// ReadValues reads values from the terragrunt.values.hcl or terragrunt.values.json file in the specified directory.
func ReadValues(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, directory string) (*cty.Value, error) {
if directory == "" {
return nil, errors.New("ReadValues: directory path cannot be empty")
}

filePath := filepath.Join(directory, valuesFile)
// Try JSON file first, then HCL file
jsonFilePath := filepath.Join(directory, valuesFileJSON)
hclFilePath := filepath.Join(directory, valuesFile)

var filePath string

if util.FileNotExists(filePath) {
switch {
case util.FileExists(jsonFilePath):
filePath = jsonFilePath
case util.FileExists(hclFilePath):
filePath = hclFilePath
default:
return nil, nil
}

Expand Down
Loading