Skip to content

Commit

Permalink
Stacks: stack values (#3961)
Browse files Browse the repository at this point in the history
* Add stack values field

* Stack values generation

* helpers update

* stack values generation

* Stack values generation

* stack values example

* prod app update

* add test for stack values generation

* Documentation update

* Test cleanup

* Documentation update

* Lint fixes

* Fixed lint issues

* cleanup

* Docs fix

* PR comments

* Documentation update
  • Loading branch information
denis256 authored Mar 6, 2025
1 parent 44bc515 commit f94c190
Show file tree
Hide file tree
Showing 10 changed files with 300 additions and 63 deletions.
2 changes: 1 addition & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ func ParseConfig(ctx *ParsingContext, file *hclparse.File, includeFromChild *Inc

// read unit files and add to context
if ctx.TerragruntOptions.Experiments.Evaluate(experiment.Stacks) {
unitValues, err := ReadUnitValues(ctx.Context, ctx.TerragruntOptions, filepath.Dir(file.ConfigPath))
unitValues, err := ReadValues(ctx.Context, ctx.TerragruntOptions, filepath.Dir(file.ConfigPath))
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion config/config_partial.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ func PartialParseConfig(ctx *ParsingContext, file *hclparse.File, includeFromChi

// read unit files and add to context
if ctx.TerragruntOptions.Experiments.Evaluate(experiment.Stacks) {
unitValues, err := ReadUnitValues(ctx.Context, ctx.TerragruntOptions, filepath.Dir(file.ConfigPath))
unitValues, err := ReadValues(ctx.Context, ctx.TerragruntOptions, filepath.Dir(file.ConfigPath))
if err != nil {
return nil, err
}
Expand Down
164 changes: 103 additions & 61 deletions config/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import (

const (
stackDir = ".terragrunt-stack"
unitValuesFile = "terragrunt.values.hcl"
valuesFile = "terragrunt.values.hcl"
manifestName = ".terragrunt-stack-manifest"
defaultStackFile = "terragrunt.stack.hcl"
unitDirPerm = 0755
Expand All @@ -49,9 +49,10 @@ type Unit struct {

// Stack represents the stack block in the configuration.
type Stack struct {
Name string `hcl:",label"`
Source string `hcl:"source,attr"`
Path string `hcl:"path,attr"`
Name string `hcl:",label"`
Source string `hcl:"source,attr"`
Path string `hcl:"path,attr"`
Values *cty.Value `hcl:"values,attr"`
}

// GenerateStacks generates the stack files.
Expand Down Expand Up @@ -112,7 +113,15 @@ func StackOutput(ctx context.Context, opts *options.TerragruntOptions) (map[stri
}

for _, path := range stackFiles {
stackFile, err := ReadStackConfigFile(ctx, opts, path)
// read stack values file
dir := filepath.Dir(path)
values, err := ReadValues(ctx, opts, dir)

if err != nil {
return nil, errors.New(err)
}

stackFile, err := ReadStackConfigFile(ctx, opts, path, values)

if err != nil {
return nil, errors.New(err)
Expand All @@ -137,10 +146,18 @@ func StackOutput(ctx context.Context, opts *options.TerragruntOptions) (map[stri
return unitOutputs, nil
}

// generateStackFile process single stack file.
// generateStackFile processes the Terragrunt stack configuration from the given stackFilePath,
// reads necessary values, and generates units and stacks in the target directory.
// It handles the creation of required directories and returns any errors encountered.
func generateStackFile(ctx context.Context, opts *options.TerragruntOptions, stackFilePath string) error {
stackSourceDir := filepath.Dir(stackFilePath)
stackFile, err := ReadStackConfigFile(ctx, opts, stackFilePath)

values, err := ReadValues(ctx, opts, stackSourceDir)
if err != nil {
return errors.Errorf("failed to read values from directory %s: %v", stackSourceDir, err)
}

stackFile, err := ReadStackConfigFile(ctx, opts, stackFilePath, values)

if err != nil {
return errors.Errorf("Failed to read stack file %s in %s %v", stackFilePath, stackSourceDir, err)
Expand All @@ -163,60 +180,85 @@ func generateStackFile(ctx context.Context, opts *options.TerragruntOptions, sta
return nil
}

// generateUnits processes each unit by resolving its destination path and copying files from the source.
// It then writes the unit's values file and logs any errors encountered.
// In case of an error, the function exits early.
func generateUnits(ctx context.Context, opts *options.TerragruntOptions, stackSourceDir, stackTargetDir string, units []*Unit) error {
// generateUnits iterates through a slice of Unit objects, processing each one by copying
// source files to their destination paths and writing unit-specific values.
// It logs the processing progress and returns any errors encountered during the operation.
func generateUnits(ctx context.Context, opts *options.TerragruntOptions, sourceDir, targetDir string, units []*Unit) error {
for _, unit := range units {
opts.Logger.Infof("Processing unit %s", unit.Name)

destPath := filepath.Join(stackTargetDir, unit.Path)
dest, err := filepath.Abs(destPath)

if err != nil {
return errors.Errorf("failed to get absolute path for destination '%s': %v", dest, err)
item := itemToProcess{
sourceDir: sourceDir,
targetDir: targetDir,
name: unit.Name,
path: unit.Path,
source: unit.Source,
values: unit.Values,
}

src := unit.Source
opts.Logger.Debugf("Processing unit: %s (%s) to %s", unit.Name, src, dest)
opts.Logger.Infof("Processing unit %s", unit.Name)

if err := copyFiles(ctx, opts, unit.Name, stackSourceDir, src, dest); err != nil {
if err := processItem(ctx, opts, &item); err != nil {
return err
}

// generate unit values file
if err := writeUnitValues(opts, unit, dest); err != nil {
return errors.Errorf("Failed to write unit values %v %v", unit.Name, err)
}
}

return nil
}

// generateStacks processes each stack by resolving its destination path and copying files from the source.
// It logs each operation and returns early if any error is encountered.
func generateStacks(ctx context.Context, opts *options.TerragruntOptions, stackSourceDir, stackTargetDir string, stacks []*Stack) error {
func generateStacks(ctx context.Context, opts *options.TerragruntOptions, sourceDir, targetDir string, stacks []*Stack) error {
for _, stack := range stacks {
opts.Logger.Infof("Processing stack %s", stack.Name)

destPath := filepath.Join(stackTargetDir, stack.Path)
dest, err := filepath.Abs(destPath)

if err != nil {
return errors.Errorf("Failed to get absolute path for destination '%s': %v", dest, err)
item := itemToProcess{
sourceDir: sourceDir,
targetDir: targetDir,
name: stack.Name,
path: stack.Path,
source: stack.Source,
values: stack.Values,
}

src := stack.Source
opts.Logger.Debugf("Processing stack: %s (%s) to %s", stack.Name, src, dest)
opts.Logger.Infof("Processing stack %s", stack)

if err := copyFiles(ctx, opts, stack.Name, stackSourceDir, src, dest); err != nil {
if err := processItem(ctx, opts, &item); err != nil {
return err
}
}

return nil
}

type itemToProcess struct {
sourceDir string
targetDir string
name string
path string
source string
values *cty.Value
}

// processItem copies files from the source directory to the target destination and generates a corresponding values file.
func processItem(ctx context.Context, opts *options.TerragruntOptions, item *itemToProcess) error {
destPath := filepath.Join(item.targetDir, item.path)
dest, err := filepath.Abs(destPath)

if err != nil {
return errors.Errorf("failed to get absolute path for destination '%s': %v", dest, err)
}

opts.Logger.Debugf("Processing: %s (%s) to %s", item.name, item.source, dest)

if err := copyFiles(ctx, opts, item.name, item.sourceDir, item.source, dest); err != nil {
return err
}

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

return nil
}

// copyFiles copies files or directories from a source to a destination path.
//
// The function checks if the source is local or remote. If local, it copies the
Expand Down Expand Up @@ -319,15 +361,20 @@ func (u *Unit) ReadOutputs(ctx context.Context, opts *options.TerragruntOptions,
// ReadStackConfigFile reads and parses a Terragrunt stack configuration file from the given path.
// It creates a parsing context, processes locals, and decodes the file into a StackConfigFile struct.
// Validation is performed on the resulting config, and any encountered errors cause an early return.
func ReadStackConfigFile(ctx context.Context, opts *options.TerragruntOptions, filePath string) (*StackConfigFile, error) {
func ReadStackConfigFile(ctx context.Context, opts *options.TerragruntOptions, filePath string, values *cty.Value) (*StackConfigFile, error) {
opts.Logger.Debugf("Reading Terragrunt stack config file at %s", filePath)

parser := NewParsingContext(ctx, opts)

if values != nil {
parser = parser.WithValues(values)
}

file, err := hclparse.NewParser(parser.ParserOptions...).ParseFromFile(filePath)
if err != nil {
return nil, errors.New(err)
}

//nolint:contextcheck
if err := processLocals(parser, opts, file); err != nil {
return nil, errors.New(err)
Expand All @@ -350,29 +397,24 @@ func ReadStackConfigFile(ctx context.Context, opts *options.TerragruntOptions, f
return config, nil
}

// writeUnitValues generates and writes unit values to a terragrunt.values.hcl file in the specified unit directory.
// If the unit has no values (Values is nil), the function logs a debug message and returns.
// Parameters:
// - opts: TerragruntOptions containing logger and other configuration
// - unit: Unit containing the values to write
// - unitDirectory: Target directory where the values file will be created
//
// Returns an error if the directory creation or file writing fails.
func writeUnitValues(opts *options.TerragruntOptions, unit *Unit, unitDirectory string) error {
if unitDirectory == "" {
return errors.New("writeUnitValues: unit directory path cannot be empty")
// writeValues generates and writes values to a terragrunt.values.hcl file in the specified directory.
func writeValues(opts *options.TerragruntOptions, values *cty.Value, directory string) error {
if values == nil {
opts.Logger.Debugf("No values to write in %s", directory)
return nil
}

if err := os.MkdirAll(unitDirectory, unitDirPerm); err != nil {
return errors.Errorf("failed to create directory %s: %w", unitDirectory, err)
if directory == "" {
return errors.New("writeValues: unit directory path cannot be empty")
}

filePath := filepath.Join(unitDirectory, unitValuesFile)
if unit.Values == nil {
opts.Logger.Debugf("No values to write for unit %s in %s", unit.Name, filePath)
return nil
if err := os.MkdirAll(directory, unitDirPerm); err != nil {
return errors.Errorf("failed to create directory %s: %w", directory, err)
}

opts.Logger.Debugf("Writing values file in %s", directory)
filePath := filepath.Join(directory, valuesFile)

file := hclwrite.NewEmptyFile()
body := file.Body()
body.AppendUnstructuredTokens([]*hclwrite.Token{
Expand All @@ -382,7 +424,7 @@ func writeUnitValues(opts *options.TerragruntOptions, unit *Unit, unitDirectory
},
})

for key, val := range unit.Values.AsValueMap() {
for key, val := range values.AsValueMap() {
body.SetAttributeValue(key, val)
}

Expand All @@ -393,13 +435,13 @@ func writeUnitValues(opts *options.TerragruntOptions, unit *Unit, unitDirectory
return nil
}

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

filePath := filepath.Join(unitDirectory, unitValuesFile)
filePath := filepath.Join(directory, valuesFile)

if util.FileNotExists(filePath) {
return nil, nil
Expand Down
15 changes: 15 additions & 0 deletions docs/_docs/04_reference/04-config-blocks-and-attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -1701,6 +1701,7 @@ The `stack` block supports the following arguments:
- `name` (label): A unique identifier for the stack. This is used to reference the stack elsewhere in your configuration.
- `source` (attribute): Specifies where to find the Terragrunt configuration files for this stack. This follows the same syntax as the `source` parameter in the `terraform` block.
- `path` (attribute): The relative path within `.terragrunt-stack` where this stack should be generated.If an absolute path is provided here, Terragrunt will generate the stack in that location, instead of generating it in a path relative to the `.terragrunt-stack` directory.
- `values` (attribute, optional): A map of custom values that can be passed to the stack. These values can be referenced within the stack's configuration files, allowing for customization without modifying the stack source.

Example:

Expand All @@ -1709,10 +1710,24 @@ Example:
stack "services" {
source = "github.com/gruntwork-io/terragrunt-stacks//stacks/mock/services?ref=v0.0.1"
path = "services"
values = {
project = "dev-services"
cidr = "10.0.0.0/16"
}
}
# github.com/gruntwork-io/terragrunt-stacks//stacks/mock/services/terragrunt.stack.hcl
# ...
unit "vpc" {
# ...
values = {
cidr = values.cidr
}
}
```

In this example, the `services` stack is defined with path `services`, which will be generated at `.terragrunt-stack/services`.
The stack is also provided with custom values for `project` and `cidr`, which can be used within the stack's configuration files.
Terragrunt will recursively generate a stack using the contents of the `.terragrunt-stack/services/terragrunt.stack.hcl` file until the entire stack is fully generated.

## Attributes
Expand Down
19 changes: 19 additions & 0 deletions test/fixtures/stacks/stack-values/project/terragrunt.stack.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@

stack "dev" {
source = "${get_repo_root()}/stacks/dev"
path = "dev"
values = {
project = "dev-project"
env = "dev"
}
}

stack "prod" {
source = "${get_repo_root()}/stacks/prod"
path = "prod"
values = {
project = "prod-project"
env = "prod"
}
}

19 changes: 19 additions & 0 deletions test/fixtures/stacks/stack-values/stacks/dev/terragrunt.stack.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
unit "dev-app-1" {
source = "${get_repo_root()}/units/app"
path = "dev-app-1"
values = {
project = values.project
env = values.env
data = "dev-app-1"
}
}

unit "dev-app-2" {
source = "${get_repo_root()}/units/app"
path = "dev-app-2"
values = {
project = values.project
env = values.env
data = "dev-app-2"
}
}
19 changes: 19 additions & 0 deletions test/fixtures/stacks/stack-values/stacks/prod/terragrunt.stack.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
unit "prod-app-1" {
source = "${get_repo_root()}/units/app"
path = "prod-app-1"
values = {
project = values.project
env = values.env
data = "prod-app-1"
}
}

unit "prod-app-2" {
source = "${get_repo_root()}/units/app"
path = "prod-app-2"
values = {
project = values.project
env = values.env
data = "prod-app-2"
}
}
Loading

0 comments on commit f94c190

Please sign in to comment.