
Terragrunt Validator
- 423 installs
- 286 repo stars
- Updated July 26, 2026
- akin-ozer/cc-devops-skills
terragrunt-validator is a DevOps agent skill that lints, validates, and security-scans Terragrunt and Terraform layouts for developers who need infrastructure changes to fail fast in review instead of production.
About
terragrunt-validator is a skill in akin-ozer/cc-devops-skills—part of a devops-skills plugin with generator/validator pairs for common DevOps tools. It targets Terragrunt 0.93+ HCL files including terragrunt.hcl and terragrunt.stack.hcl, plus Terragrunt Stacks workflows with unit/stack blocks. Core checks span HCL formatting (`terragrunt hcl fmt --check`), input validation, Terragrunt and Terraform syntax validation, tflint linting, Trivy and Checkov security scans, dependency graph validation, and dry-run planning. Scripts include validate_terragrunt.sh for full pipelines and detect_custom_resources.py for custom provider and module discovery. Developers reach for terragrunt-validator in PR review or CI when multi-module Terragrunt repos need repeatable fail-fast checks before merge or apply.
- Checks Terragrunt structure and Terraform module wiring
- Catches misconfigurations before terraform apply
- Supports IaC review in PR and pre-deploy gates
- Aligns with GitOps and environment promotion flows
Terragrunt Validator by the numbers
- 423 all-time installs (skills.sh)
- Ranked #282 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/akin-ozer/cc-devops-skills --skill terragrunt-validatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 423 |
|---|---|
| repo stars | ★ 286 |
| Last updated | July 26, 2026 |
| Repository | akin-ozer/cc-devops-skills ↗ |
How do you validate Terragrunt configs before apply?
Validate Terragrunt and Terraform layout, module references, and configuration before apply so infrastructure changes fail fast in review instead of production.
Who is it for?
Platform engineers maintaining Terragrunt 0.93+ monorepos who want scripted lint, security scan, and plan checks in CI or pre-merge review.
Skip if: Teams on raw Terraform-only repos without Terragrunt wrappers, or greenfield infra with no .hcl files to validate yet.
When should I use this skill?
The user asks to validate, lint, or security-scan Terragrunt HCL, debug terragrunt.hcl errors, or run pre-apply checks on infrastructure code.
What you get
Validation reports covering HCL format, terraform validate, tflint results, Trivy/Checkov findings, dependency graphs, and plan dry-run summaries.
- validation report
- security scan findings
- dependency graph output
By the numbers
- Targets Terragrunt 0.93+ including Stacks support from v0.78.0+
- Integrates tflint, Trivy, and Checkov in the validation pipeline
Files
Terragrunt Validator
Overview
This skill provides comprehensive validation, linting, and testing capabilities for Terragrunt configurations. Terragrunt is a thin wrapper for Terraform/OpenTofu that provides extra tools for keeping configurations DRY (Don't Repeat Yourself), working with multiple modules, and managing remote state.
Use this skill when:
- Validating Terragrunt HCL files (*.hcl, terragrunt.hcl, terragrunt.stack.hcl)
- Working with Terragrunt Stacks (unit/stack blocks,
terragrunt stack generate/run) - Performing dry-run testing with
terragrunt plan - Linting Terragrunt/Terraform code for best practices
- Detecting and researching custom providers or modules
- Debugging Terragrunt configuration issues
- Checking dependency graphs
- Formatting HCL files
- Running security scans on infrastructure code (Trivy, Checkov)
- Generating run reports and summaries
Terragrunt Version Compatibility
This skill is designed for Terragrunt 0.93+ which includes the new CLI redesign.
CLI Command Migration Reference
| Deprecated Command | New Command |
|---|---|
run-all | run --all |
hclfmt | hcl fmt |
hclvalidate | hcl validate |
validate-inputs | hcl validate --inputs |
graph-dependencies | dag graph |
render-json | render --json -w |
terragrunt-info | info print |
plan-all, apply-all | run --all plan, run --all apply |
Key Changes in 0.93+:
terragrunt run --allreplacesterragrunt run-allfor multi-module operationsterragrunt dag graphreplacesterragrunt graph-dependenciesfor dependency visualizationterragrunt hcl validate --inputsreplacesvalidate-inputsfor input validation- HCL syntax validation via
terragrunt hcl fmt --checkorterragrunt hcl validate - Full validation requires
terragrunt init && terragrunt validate
If using an older Terragrunt version, some commands may need adjustment.
Core Capabilities
1. Comprehensive Validation Suite
Run the comprehensive validation script to perform all checks at once:
bash scripts/validate_terragrunt.sh [TARGET_DIR]What it validates:
- HCL formatting (
terragrunt hcl fmt --check) - HCL input validation (
terragrunt hcl validate --inputs) - Terragrunt configuration syntax
- Terraform configuration validation
- Linting with tflint
- Security scanning with Trivy (or legacy tfsec)
- Dependency graph validation
- Dry-run planning
Environment variables:
SKIP_PLAN=true- Skip terragrunt plan stepSKIP_SECURITY=true- Skip security scanning (Trivy/tfsec)SKIP_LINT=true- Skip tflint lintingSKIP_INIT=true- Skipterragrunt initbefore validationSKIP_BACKEND_INIT=true- Run init with-backend=false(useful in CI/offline)SOFT_FAIL_SECURITY=true- Report security findings without failingTG_STRICT_MODE=true- Enable strict mode (errors on deprecated features)
Example usage:
# Full validation
bash scripts/validate_terragrunt.sh ./infrastructure/prod
# Skip plan generation (faster)
SKIP_PLAN=true bash scripts/validate_terragrunt.sh ./infrastructure
# Only validate, skip linting and security
SKIP_LINT=true SKIP_SECURITY=true bash scripts/validate_terragrunt.sh2. Custom Provider and Module Detection
Use the detection script to identify custom providers and modules that may require documentation lookup:
python3 scripts/detect_custom_resources.py [DIRECTORY] [--format text|json]What it detects:
- Custom Terraform providers (non-HashiCorp)
- Remote modules (Git, Terraform Registry, HTTP)
- Provider versions
- Module versions and sources
Output formats:
text- Human-readable report with search recommendationsjson- Machine-readable format for automation
When custom resources are detected:
CRITICAL: You MUST look up documentation for EVERY detected custom resource (both providers AND modules). Do NOT skip any. This is mandatory, not optional.
1. For custom providers:
- Option A - WebSearch: Search for provider documentation
- Query format:
"{provider_source} terraform provider documentation version {version}" - Example:
"mongodb/mongodbatlas terraform provider documentation version 1.14.0" - Option B - Context7 MCP (Preferred): Use Context7 for structured documentation lookup
- Step 1: Resolve library ID:
mcp__context7__resolve-library-idwith provider name (e.g., "datadog terraform provider") - Step 2: REQUIRED - Fetch docs via
mcp__context7__query-docswith the resolved library ID - Use queries like
"authentication requirements"and"configuration examples"
2. For custom modules (EQUALLY IMPORTANT - DO NOT SKIP):
- Terraform Registry modules:
- Use Context7:
mcp__context7__resolve-library-idwith module name (e.g., "terraform-aws-modules vpc") - Then fetch docs with
mcp__context7__query-docs - Or visit
https://registry.terraform.io/modules/{source}/{version} - Git modules: Use WebSearch with the repository URL to find README or documentation
- HTTP modules: Investigate the source URL for documentation
- Pay attention to version compatibility with your Terraform/Terragrunt version
3. Documentation lookup workflow (MANDATORY for ALL detected resources):
a) Run detect_custom_resources.py
b) For EACH custom provider/module:
- Note the exact version
- Use Context7 MCP:
1. mcp__context7__resolve-library-id with libraryName: "{provider/module name}"
2. mcp__context7__query-docs with:
- libraryId: "{resolved ID}"
- query: "authentication requirements" (for auth requirements)
3. mcp__context7__query-docs with:
- libraryId: "{resolved ID}"
- query: "configuration examples" (for setup requirements)
- OR use WebSearch with version-specific queries
- Review documentation for:
* Required configuration blocks
* Authentication requirements (API keys, credentials)
* Available resources/data sources
* Known issues or breaking changes in the version
c) Apply learnings to validation/troubleshooting
d) Document findings if issues are encounteredExample using Context7 MCP:
# 1. Detect custom resources
python3 scripts/detect_custom_resources.py ./infrastructure
# Output: Provider: datadog/datadog, Version: 3.30.0
# 2. Resolve library ID
mcp__context7__resolve-library-id with libraryName: "datadog terraform provider"
# Result: /datadog/terraform-provider-datadog
# 3. Fetch authentication docs (REQUIRED)
mcp__context7__query-docs with:
libraryId: "/datadog/terraform-provider-datadog"
query: "authentication requirements"
# 4. Fetch configuration docs
mcp__context7__query-docs with:
libraryId: "/datadog/terraform-provider-datadog"
query: "configuration examples"Example using WebSearch:
# Detect custom resources
python3 scripts/detect_custom_resources.py ./infrastructure
# Then search for documentation:
# WebSearch: "datadog terraform provider 3.30.0 authentication configuration"
# WebSearch: "datadog terraform provider api_key app_key setup"3. Step-by-Step Validation
For manual or granular validation, use these individual commands:
Format Validation
cd <target-directory>
terragrunt hcl fmt --check
# To auto-fix formatting
terragrunt hcl fmtConfiguration Validation
# Check HCL syntax and formatting
terragrunt hcl fmt --check
# Note: In Terragrunt 0.93+, for deeper configuration validation,
# initialize and validate (requires actual resources/credentials):
# terragrunt init && terragrunt validateTerraform Validation
# Initialize if needed
terragrunt init
# Validate
terragrunt validateLinting with tflint
# Initialize tflint (if .tflint.hcl exists)
tflint --init
# Run linting
tflint --recursiveSecurity Scanning with Trivy (Recommended)
Note: tfsec has been merged into Trivy and is no longer actively maintained.
Use Trivy for all new projects.
# Using Trivy (recommended)
trivy config . --severity HIGH,CRITICAL
# With tfvars file
trivy config --tf-vars terraform.tfvars .
# Exclude downloaded modules
trivy config --tf-exclude-downloaded-modules .
# Legacy: Using tfsec (deprecated)
tfsec . --soft-failAlternative: Security Scanning with Checkov
# Scan directory
checkov -d . --framework terraform
# Scan with specific checks
checkov -d . --check CKV_AWS_21
# Output as JSON
checkov -d . --output jsonDependency Graph Validation
# Note: graph-dependencies command replaced with 'dag graph' in Terragrunt 0.93+
# Validate and display dependency graph
terragrunt dag graph
# Visualize dependencies (requires graphviz)
terragrunt dag graph | dot -Tpng > dependencies.pngDry-Run Planning
# Single module
terragrunt plan
# All modules (new syntax - Terragrunt 0.93+)
terragrunt run --all plan
# Legacy syntax (deprecated)
# terragrunt run-all plan4. Multi-Module Operations
For projects with multiple Terragrunt modules, use run --all (replaces deprecated run-all):
# Validate all modules
terragrunt run --all validate
# Plan all modules
terragrunt run --all plan
# Apply all modules
terragrunt run --all apply
# Destroy all modules
terragrunt run --all destroy
# Format all HCL files
terragrunt hcl fmt
# With parallelism
terragrunt run --all plan --parallelism 4
# With strict mode (errors on deprecated features)
terragrunt --strict-mode run --all plan
# Or via environment variable
TG_STRICT_MODE=true terragrunt run --all plan5. HCL Input Validation (New in 0.93+)
Validate that all required inputs are set and no unused inputs exist:
# Validate inputs
terragrunt hcl validate --inputs
# Show paths of invalid files
terragrunt hcl validate --show-config-path
# Combine with run --all to exclude invalid files
terragrunt run --all plan --queue-excludes-file <(terragrunt hcl validate --show-config-path || true)6. Strict Mode
Enable strict mode to catch deprecated features early:
# Via CLI flag
terragrunt --strict-mode run --all plan
# Via environment variable (recommended for CI/CD)
export TG_STRICT_MODE=true
terragrunt run --all plan
# Check available strict controls
terragrunt info strictSpecific Strict Controls:
For finer-grained control, use --strict-control to enable specific controls:
# Enable specific strict controls
terragrunt run --all plan --strict-control cli-redesign --strict-control deprecated-commands
# Via environment variable (comma-separated)
TG_STRICT_CONTROL='cli-redesign,deprecated-commands' terragrunt run --all plan
# Available strict controls:
# - cli-redesign: Errors on deprecated CLI syntax
# - deprecated-commands: Errors on deprecated commands (run-all, hclfmt, etc.)
# - root-terragrunt-hcl: Errors when using root terragrunt.hcl (use root.hcl instead)
# - skip-dependencies-inputs: Improves performance by not reading dependency inputs
# - bare-include: Errors on bare include blocks (use named includes)7. New CLI Commands (0.93+)
Render Configuration
# Render configuration to JSON
terragrunt render --json
# Render and write to file
terragrunt render --json --write
# Output goes to terragrunt.rendered.jsonInfo Print (replaces terragrunt-info)
# Get contextual information about current configuration
terragrunt info print
# Output includes:
# - config_path
# - download_dir
# - terraform_binary
# - working_dirFind and List Units
# Find all units/stacks in directory
terragrunt find
# Output as JSON
terragrunt find --json
# Include dependency information
terragrunt find --json --dag
# List units (simpler output)
terragrunt listRun Summary and Reports
# Run with summary output (default in newer versions)
terragrunt run --all plan
# Disable summary output
terragrunt run --all plan --summary-disable
# Generate detailed report file
terragrunt run --all plan --report-file=report.json
# CSV format report
terragrunt run --all plan --report-file=report.csv8. Terragrunt Stacks (GA in v0.78.0+)
Terragrunt Stacks provide declarative infrastructure generation using terragrunt.stack.hcl files.
Stack File Structure
# terragrunt.stack.hcl
locals {
environment = "dev"
aws_region = "us-east-1"
}
# Define a unit (generates a single terragrunt.hcl)
unit "vpc" {
source = "git::git@github.com:acme/infra-catalog.git//units/vpc?ref=v0.0.1"
path = "vpc"
values = {
environment = local.environment
cidr = "10.0.0.0/16"
}
}
unit "database" {
source = "git::git@github.com:acme/infra-catalog.git//units/database?ref=v0.0.1"
path = "database"
values = {
environment = local.environment
vpc_path = "../vpc"
}
}
# Include reusable stacks
stack "monitoring" {
source = "git::git@github.com:acme/infra-catalog.git//stacks/monitoring?ref=v0.0.1"
path = "monitoring"
values = {
environment = local.environment
}
}Stack Commands
# Generate stack (creates .terragrunt-stack directory)
terragrunt stack generate
# Generate stack without validation
terragrunt stack generate --no-stack-validate
# Run command on all stack units
terragrunt stack run plan
terragrunt stack run apply
# Clean generated stack directories
terragrunt stack clean
# Get stack outputs
terragrunt stack outputStack Validation Control
Use no_validation attribute to skip validation for specific units:
unit "experimental" {
source = "git::git@github.com:acme/infra-catalog.git//units/experimental?ref=v0.0.1"
path = "experimental"
# Skip validation for this unit (useful for incomplete/experimental units)
no_validation = true
values = {
environment = local.environment
}
}Benefits of Stacks
- Clean working directory: Generated code in hidden
.terragrunt-stackdirectory - Reusable patterns: Define infrastructure patterns once, deploy many times
- Version pinning: Different environments can pin different versions
- Atomic updates: Easy rollbacks of both modules and configurations
9. Exec Command (Run Arbitrary Programs)
The exec command allows you to run arbitrary programs against units with Terragrunt context. This is useful for integrating other tools like tflint, checkov, or AWS CLI with Terragrunt's configuration.
# Run tflint with unit context (TF_VAR_ env vars available)
terragrunt exec -- tflint
# Run checkov against specific unit
terragrunt exec -- checkov -d .
# Run AWS CLI with unit's configuration
terragrunt exec -- aws s3 ls s3://my-bucket
# Run custom scripts with Terragrunt context
terragrunt exec -- ./scripts/validate_terragrunt.sh
# Run across all units
terragrunt run --all exec -- tflintKey Features:
- Terragrunt loads the inputs for the unit and makes them available as
TF_VAR_prefixed environment variables - Works with any program that can use environment variables
- Integrates with Terragrunt's authentication context (e.g., AWS profiles)
- Can be combined with
run --allfor multi-unit operations
Use Cases:
- Running security scanners (checkov, trivy) with unit context
- Executing linters (tflint) per unit
- Running operational commands (AWS CLI) with correct credentials
- Custom validation scripts that need Terragrunt inputs
10. Feature Flags (Production Feature)
Terragrunt supports first-class Feature Flags for safe infrastructure changes. Feature flags allow you to integrate incomplete work without risk, decouple release from deployment, and codify IaC evolution.
Defining Feature Flags
# terragrunt.hcl
feature "enable_monitoring" {
default = false
}
feature "use_new_vpc" {
default = true
}
inputs = {
monitoring_enabled = feature.enable_monitoring.value
vpc_version = feature.use_new_vpc.value ? "v2" : "v1"
}Using Feature Flags via CLI
# Enable a feature flag
terragrunt plan --feature enable_monitoring=true
# Enable multiple feature flags
terragrunt plan --feature enable_monitoring=true --feature use_new_vpc=false
# Via environment variable
TG_FEATURE='enable_monitoring=true' terragrunt planFeature Flags with run --all
# Apply feature flag across all units
terragrunt run --all plan --feature enable_monitoring=trueBenefits:
- Safe rollouts: Test changes on subset of infrastructure
- Gradual migrations: Enable new features incrementally
- A/B testing: Compare infrastructure configurations
- Emergency rollbacks: Quickly disable problematic features
11. Experiments (Opt-in Unstable Features)
Terragrunt provides an experiments system for trying unstable features before they're GA:
# Enable all experiments (not recommended for production)
terragrunt --experiment-mode run --all plan
# Enable specific experiment
terragrunt --experiment symlinks run --all plan
# Enable CAS (Content Addressable Storage) for faster cloning
terragrunt --experiment cas run --all planAvailable Experiments:
symlinks- Support symlink resolution for Terragrunt unitscas- Content Addressable Storage for faster Git/module cloningfilter-flag- Advanced filtering capabilities (coming in 1.0)
Validation Workflow
Follow this workflow when validating Terragrunt configurations:
Canonical Executable Workflow (Default Path)
Use one executable path so docs and scripts stay aligned:
# Main validation
bash scripts/validate_terragrunt.sh <target-directory>
# Deterministic fixture tests (required after script changes)
python3 test/test_detect_custom_resources.py
bash test/test_validate_terragrunt.shExecution expectations:
- Fixture tests should be deterministic (stable pass/fail outcomes).
- Validation/security failures must surface as non-zero exits.
Step 0: Read Best Practices Reference (MANDATORY FIRST STEP)
You MUST read the best practices reference file BEFORE starting validation. This is not optional.
# Read the best practices reference file first
if [ -f references/best_practices.md ]; then
cat references/best_practices.md
else
echo "WARNING: references/best_practices.md not found; continue with built-in checklist below."
fiThis ensures you understand the patterns, anti-patterns, and checklists you will verify.
Initial Assessment
1. Understand the structure:
tree -L 3 <infrastructure-directory>2. Identify Terragrunt files:
find . -name "*.hcl" -o -name "terragrunt.hcl"3. Detect custom resources:
python3 scripts/detect_custom_resources.py .Documentation Lookup (MANDATORY for ALL detected custom resources)
CRITICAL: If ANY custom providers or modules are detected, you MUST look up documentation for EACH ONE. Do not skip any.
4. For EACH detected custom provider - look up documentation:
- Use Context7 MCP (preferred):
1. mcp__context7__resolve-library-id with provider name 2. mcp__context7__query-docs with query: "authentication requirements" 3. mcp__context7__query-docs with query: "configuration examples"
- OR use WebSearch:
"{provider} terraform provider {version} documentation"
5. For EACH detected custom module - look up documentation:
- Use Context7 MCP for Terraform Registry modules:
1. mcp__context7__resolve-library-id with module name (e.g., "terraform-aws-modules vpc") 2. mcp__context7__query-docs with relevant configuration query
- For Git modules: Use WebSearch with repository URL
- For HTTP modules: Investigate source URL for documentation
6. Document findings for each resource:
- Required configuration blocks
- Authentication requirements
- Known issues or breaking changes in the version
Validation Execution
7. Run comprehensive validation:
bash scripts/validate_terragrunt.sh <target-directory>8. Review output for errors:
- Format errors → Fix with
terragrunt hcl fmt - Configuration errors → Check terragrunt.hcl syntax and inputs
- Terraform validation errors → Check .tf files or generated configs
- Linting issues → Review tflint output and fix
- Security issues → Review Trivy/Checkov/tfsec output and address
- Dependency errors → Check dependency blocks and paths
- Plan errors → Review Terraform configuration and provider setup
Best Practices Check (REQUIRED - Must Complete All Checklists)
You MUST verify each checklist item below and document the result (✅ pass or ❌ fail). Incomplete verification is not acceptable.
9. Perform explicit best practices verification using `references/best_practices.md`:
Configuration Pattern Checklist - verify each item:
[ ] Include blocks: Child modules use `include "root" { path = find_in_parent_folders("root.hcl") }`
[ ] Named includes: All include blocks have names (not bare `include {}`)
[ ] Root file naming: Root config is named `root.hcl` (not `terragrunt.hcl`)
[ ] Environment configs: Environment-level configs named `env.hcl` (not `terragrunt.hcl`)
[ ] Common variables: Shared variables in `common.hcl` read via `read_terragrunt_config()`Dependency Management Checklist:
[ ] Mock outputs: ALL dependency blocks have mock_outputs for validation
[ ] Mock allowed commands: mock_outputs_allowed_terraform_commands includes ["validate", "plan", "init"]
[ ] Explicit paths: Dependency config_path uses relative paths ("../vpc" not absolute)
[ ] No circular deps: Run `terragrunt dag graph` to verify no cyclesSecurity Checklist:
[ ] State encryption: remote_state config has `encrypt = true`
[ ] State locking: DynamoDB table configured for S3 backend
[ ] No hardcoded credentials: Search for patterns like "AKIA", "password =", account IDs
[ ] Sensitive variables: Passwords/keys use `sensitive = true` in variable blocks
[ ] IAM roles: Provider uses assume_role instead of static credentialsDRY Principle Checklist:
[ ] Generate blocks: Provider and backend configs use `generate` blocks
[ ] Version constraints: terragrunt_version_constraint and terraform_version_constraint set
[ ] Reusable locals: Common values in shared files, not duplicated
[ ] if_exists: Generate blocks use appropriate if_exists strategyQuick grep checks to run:
# Check for hardcoded AWS account IDs
grep -r "[0-9]\{12\}" --include="*.hcl" . | grep -v mock
# Check for potential credentials
grep -ri "password\s*=" --include="*.hcl" .
grep -ri "api_key\s*=" --include="*.hcl" .
# Check for dependencies without mock_outputs
grep -l "dependency\s" --include="*.hcl" -r . | xargs grep -L "mock_outputs"
# Check for terragrunt.hcl files in non-module directories (anti-pattern)
find . -name "terragrunt.hcl" -not -path "*/.terragrunt-cache/*" | head -20Troubleshooting
10. Common issues and resolutions:
Issue: Module not found
rm -rf .terragrunt-cache
terragrunt initIssue: Provider authentication errors
- Check provider configuration in generated files
- Verify environment variables or credentials
- Review provider documentation from WebSearch
Issue: Dependency errors
- Check dependency paths are correct
- Ensure mock_outputs are provided for validation
- Review dependency graph with
terragrunt dag graph
Issue: State locking errors
terragrunt force-unlock <LOCK_ID>Issue: S3 backend `dynamodb_table` deprecation warning
- Recent Terraform versions may warn that
dynamodb_tableis deprecated for S3 backends. - Prefer
use_lockfile = truein backend config when compatible with your workflow. - Keep
dynamodb_tableonly for legacy compatibility needs.
Issue: Unknown provider or module parameters
- Re-run custom resource detection
- Use WebSearch to look up current documentation
- Check version compatibility
Issue: Generate block conflicts (file already exists)
ERROR: The file path ./versions.tf already exists and was not generated by terragrunt.
Can not generate terraform file: ./versions.tf already existsSolution: This occurs when static .tf files exist that conflict with Terragrunt's generate blocks. Either:
- Remove the conflicting static files (
versions.tf,provider.tf,backend.tf) - Or use
if_exists = "skip"in the generate block to not overwrite existing files
# Remove conflicting files
rm -f versions.tf provider.tf backend.tf
rm -rf .terragrunt-cacheIssue: Root terragrunt.hcl anti-pattern warning
WARN: Using `terragrunt.hcl` as the root of Terragrunt configurations is an anti-patternSolution: In Terragrunt 0.93+, the root configuration file should be named root.hcl instead of terragrunt.hcl. Rename the file:
mv terragrunt.hcl root.hcl
# Update include blocks in child modules to reference root.hclBest Practices Integration
Reference the comprehensive best practices guide for detailed recommendations:
# Read the best practices reference
if [ -f references/best_practices.md ]; then
cat references/best_practices.md
else
echo "WARNING: references/best_practices.md not found; continue with checklist in this document."
fiKey best practices to check:
- ✅ Use
includefor shared configuration - ✅ Provide mock_outputs for dependencies
- ✅ Use
generateblocks for provider config - ✅ Enable state encryption and locking
- ✅ Use environment variables for dynamic values
- ✅ Specify version constraints
- ✅ Avoid hardcoded values
- ✅ Use meaningful directory structure
- ✅ Enable security features (encryption, IAM roles)
When validating, check for anti-patterns:
- ❌ Hardcoded credentials or account IDs
- ❌ Missing mock outputs
- ❌ Overly deep directory nesting
- ❌ Duplicated configuration across modules
- ❌ Missing version constraints
- ❌ Unencrypted state
Refer to references/best_practices.md for complete examples and detailed guidance.
Tool Requirements
Required:
- terragrunt (>= 0.93.0 recommended for new CLI)
- terraform or opentofu (>= 1.6.0 recommended)
Optional but recommended:
- tflint - HCL linting
- trivy - Security scanning (replaces tfsec)
- checkov - Alternative security scanner (750+ built-in policies)
- graphviz (dot) - Dependency visualization
- jq - JSON parsing
- python3 - For custom resource detection script
Deprecated tools:
- tfsec - Merged into Trivy, no longer actively maintained
Installation commands:
# macOS
brew install terragrunt terraform tflint trivy graphviz jq
# Install Trivy (recommended security scanner)
brew install trivy
# Install Checkov (alternative security scanner)
pip3 install checkov
# Legacy tfsec (deprecated - use trivy instead)
# brew install tfsec
# Linux - Trivy
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Linux - Checkov
pip3 install checkov
# Verify installations
terragrunt --version
trivy --version
checkov --versionIntegration with Context7 MCP
If Context7 MCP is available, use it for provider/module documentation lookup:
1. Resolve library ID:
mcp__context7__resolve-library-id with libraryName: "mongodb/mongodbatlas"2. Query documentation:
mcp__context7__query-docs with libraryId: "/mongodb/mongodbatlas" and query: "authentication requirements"This provides version-aware documentation directly, as an alternative to WebSearch.
Automated Workflows
CI/CD Integration
Use the deterministic skill-level CI gate as the blocking check:
bash scripts/run_ci_checks.sh --require-shellcheckThis gate runs:
- Shell syntax checks (
bash -n) - Python syntax checks (
python3 -m py_compile) - Python regression tests (
test/test_detect_custom_resources.py) - Shell regression tests (
test/test_validate_terragrunt.sh) - ShellCheck linting (required in CI when
--require-shellcheckis set)
After that gate passes, run environment-dependent validation in jobs that have Terragrunt/Terraform credentials configured:
#!/bin/bash
# ci-validate.sh
set -euo pipefail
echo "Running deterministic validator checks..."
bash scripts/run_ci_checks.sh --require-shellcheck
echo "Installing dependencies..."
# Install terragrunt, terraform, tflint, trivy/checkov
echo "Detecting custom resources..."
python3 scripts/detect_custom_resources.py . --format json > custom_resources.json
# Could integrate with automated documentation lookup here
echo "Running validation suite..."
SKIP_PLAN=true SKIP_BACKEND_INIT=true bash scripts/validate_terragrunt.sh .
echo "Validation complete!"Pre-commit Hook
Example pre-commit hook for local development:
#!/bin/bash
# .git/hooks/pre-commit
# Format check
terragrunt hcl fmt --check || {
echo "HCL formatting issues found. Run: terragrunt hcl fmt"
exit 1
}
# Quick HCL syntax validation (Terragrunt 0.93+)
# Note: For full validation, use: terragrunt init && terragrunt validate
# But that requires credentials. HCL format check catches syntax errors.
echo "Pre-commit validation passed!"Troubleshooting Guide
Validation Modes and Exit Semantics
validate_terragrunt.sh derives mode from the target directory and changes the Terragrunt command path accordingly:
| Mode | Directory shape | Terragrunt HCL check | Terraform check | Exit semantics |
|---|---|---|---|---|
single | terragrunt.hcl (or terragrunt.stack.hcl) in target dir | terragrunt hcl validate | terragrunt validate (with terragrunt init unless skipped) | Any syntax/validate failure exits non-zero |
multi | Nested units exist below target | terragrunt hcl validate --all (fallback to plain hcl validate if --all is unsupported) | terragrunt run --all validate (with run --all init unless skipped) | Any unit failure exits non-zero |
root-only | root.hcl only, no unit in target dir | Warn and skip | Warn and skip | Returns success (0) for these skipped steps |
none | No recognized Terragrunt config files | Error | Error | Returns non-zero |
Debug Mode
Enable debug output for troubleshooting:
# Terragrunt debug
TERRAGRUNT_DEBUG=1 terragrunt plan
# Terraform trace
TF_LOG=TRACE terragrunt planCommon Error Patterns
"Error: Module not found"
- Clear cache:
rm -rf .terragrunt-cache - Re-initialize:
terragrunt init
"Error: Provider not found"
- Check provider configuration
- Run custom resource detection
- Use WebSearch to find correct provider source and version
- Verify required_providers block
"Error: Invalid function call"
- Check Terragrunt version compatibility
- Review function syntax in documentation
"Cycle detected in dependency graph"
- Review dependency chains
- Consider refactoring into single module
- Use data sources instead of dependencies
"Error acquiring state lock"
- Check if another process is running
- Verify DynamoDB table (for S3 backend)
- Force unlock if safe:
terragrunt force-unlock <LOCK_ID>
"Error: unknown command" (Terragrunt 0.93+)
- Terragrunt 0.93+ has a new CLI with breaking changes
- Commands like
render-json,validate-inputsare deprecated - Use
terragrunt run -- <command>for custom/unsupported commands - Replace
graph-dependencieswithdag graph - See: https://terragrunt.gruntwork.io/docs/migrate/cli-redesign/
Output Interpretation
Success Indicators
✅ All checks passing:
- All HCL files properly formatted
- Inputs are valid
- Terraform configuration is valid
- No linting issues
- No critical security issues
- Valid dependency graph
- Plan generated successfully
Warning Indicators
⚠️ Review needed:
- Security warnings from Trivy/Checkov/tfsec (non-critical)
- Linting suggestions (best practices)
- Deprecated provider features
- Missing recommended configurations
Error Indicators
✗ Must fix:
- Format errors
- Invalid inputs
- Terraform validation failures
- Circular dependencies
- Provider authentication failures
- State locking errors
Advanced Usage
Custom Validation Rules
Create custom tflint rules by adding .tflint.hcl:
plugin "terraform" {
enabled = true
preset = "recommended"
}
plugin "aws" {
enabled = true
version = "0.27.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
rule "terraform_naming_convention" {
enabled = true
}Custom Security Policies
Create custom tfsec policies by adding .tfsec/config.yml:
minimum_severity: MEDIUM
exclude:
- AWS001 # Example: exclude specific rulesDependency Graph Analysis
Analyze complex dependency chains:
# Generate detailed graph (Terragrunt 0.93+ syntax)
terragrunt dag graph > graph.dot
# Convert to visual format
dot -Tpng graph.dot > graph.png
dot -Tsvg graph.dot > graph.svg
# Analyze for circular dependencies
grep -A5 "cycle" <(terragrunt dag graph 2>&1)Resources
Scripts
scripts/validate_terragrunt.sh- Comprehensive validation suitescripts/detect_custom_resources.py- Custom provider/module detector
References
references/best_practices.md- Comprehensive best practices guide covering:- Directory structure patterns
- DRY principles and configuration sharing
- Dependency management
- Security best practices
- Testing and validation workflows
- Common anti-patterns to avoid
- Troubleshooting guides
External Documentation
Done Criteria
- Docs and scripts agree on one canonical executable workflow.
- Fixture runs are deterministic via:
python3 test/test_detect_custom_resources.pybash test/test_validate_terragrunt.sh- Validation and security failures are reported with correct non-zero exits.
# Terragrunt/Terraform generated directories
**/.terragrunt-cache/
**/.terraform/
Terragrunt Best Practices and Common Patterns
Overview
This reference document provides best practices, common patterns, and anti-patterns for Terragrunt configurations. Use this as a guide when validating or creating Terragrunt code.
Directory Structure
Recommended Structure
infrastructure/
├── root.hcl # Root Terragrunt config (Terragrunt 0.93+)
├── common.hcl # Shared configuration
├── prod/
│ ├── env.hcl # Environment-level config
│ ├── vpc/
│ │ └── terragrunt.hcl # Module-specific config
│ ├── database/
│ │ └── terragrunt.hcl
│ └── app/
│ └── terragrunt.hcl
├── staging/
│ └── ... (similar structure)
└── dev/
└── ... (similar structure)Anti-Pattern: Flat Structure
❌ Avoid flat structures without environment separation:
infrastructure/
├── vpc.hcl
├── database.hcl
├── app.hclDRY Principles
Use include for Shared Configuration
✅ Good Practice:
# Root root.hcl (Terragrunt 0.93+)
remote_state {
backend = "s3"
config = {
bucket = "my-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
# Child terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}Use read_terragrunt_config for Shared Variables
✅ Good Practice:
# common.hcl
locals {
region = "us-east-1"
environment = "prod"
tags = {
Terraform = "true"
Environment = local.environment
}
}
# terragrunt.hcl
locals {
common = read_terragrunt_config(find_in_parent_folders("common.hcl"))
}
inputs = {
region = local.common.locals.region
tags = local.common.locals.tags
}Dependencies
Explicit Dependencies
✅ Good Practice:
dependency "vpc" {
config_path = "../vpc"
}
dependency "database" {
config_path = "../database"
# Mock outputs for validation
mock_outputs = {
endpoint = "mock-db-endpoint"
port = 5432
}
# Allow mock outputs during plan
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
database_endpoint = dependency.database.outputs.endpoint
}Anti-Pattern: Implicit Dependencies via Remote State
❌ Avoid accessing remote state directly:
# This makes dependencies unclear
inputs = {
vpc_id = data.terraform_remote_state.vpc.outputs.vpc_id
}Mock Outputs for Testing
Provide Mock Outputs
✅ Good Practice:
dependency "network" {
config_path = "../network"
mock_outputs = {
vpc_id = "vpc-mock123"
subnet_ids = ["subnet-mock1", "subnet-mock2"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
mock_outputs_merge_strategy_with_state = "shallow"
}This allows running terragrunt plan without deploying dependencies first.
Generate Blocks
Use generate for Provider Configuration
✅ Good Practice:
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "${local.region}"
assume_role {
role_arn = "arn:aws:iam::${local.account_id}:role/TerraformRole"
}
default_tags {
tags = ${jsonencode(local.tags)}
}
}
EOF
}Use generate for Backend Configuration
✅ Good Practice:
generate "backend" {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
terraform {
backend "s3" {}
}
EOF
}Input Variables
Use inputs Block
✅ Good Practice:
inputs = {
environment = local.environment
region = local.region
# Use dependency outputs
vpc_id = dependency.vpc.outputs.vpc_id
# Use functions
instance_count = get_env("INSTANCE_COUNT", 3)
# Merge tags
tags = merge(
local.common_tags,
{
Module = "app"
}
)
}Anti-Pattern: Duplicating Inputs
❌ Avoid repeating the same inputs:
# Don't do this across multiple modules
inputs = {
region = "us-east-1" # Repeated everywhere
tags = { # Repeated everywhere
Terraform = "true"
}
}terraform Block
Specify Terraform and Provider Versions
✅ Good Practice:
terraform {
source = "tfr:///terraform-aws-modules/vpc/aws?version=5.1.0"
}
generate "versions" {
path = "versions.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
EOF
}Error Handling
Use get_env with Defaults
✅ Good Practice:
locals {
account_id = get_env("AWS_ACCOUNT_ID", "")
}
# Validate required environment variables
inputs = {
account_id = local.account_id != "" ? local.account_id : run_cmd("--terragrunt-quiet", "aws", "sts", "get-caller-identity", "--query", "Account", "--output", "text")
}Use try for Optional Values
✅ Good Practice:
locals {
env_config = read_terragrunt_config(find_in_parent_folders("env.hcl", "empty.hcl"))
# Safely access potentially missing values
instance_type = try(local.env_config.locals.instance_type, "t3.micro")
}Common Anti-Patterns
1. Hardcoding Values
❌ Bad:
inputs = {
region = "us-east-1" # Hardcoded
account_id = "123456789012" # Hardcoded
}✅ Good:
locals {
region = get_env("AWS_REGION", "us-east-1")
account_id = get_aws_account_id()
}
inputs = {
region = local.region
account_id = local.account_id
}2. Not Using Mock Outputs
❌ Bad:
dependency "vpc" {
config_path = "../vpc"
# No mock outputs - can't validate without deploying vpc
}3. Deep Nesting
❌ Bad:
infrastructure/
└── prod/
└── us-east-1/
└── vpc/
└── public/
└── subnet-1/
└── terragrunt.hcl✅ Good:
infrastructure/
└── prod/
└── vpc/
└── terragrunt.hcl # Configure all subnets here4. Not Using Functions
❌ Bad:
# Manually maintaining paths
remote_state {
config = {
key = "prod/vpc/terraform.tfstate"
}
}✅ Good:
remote_state {
config = {
key = "${path_relative_to_include()}/terraform.tfstate"
}
}Security Best Practices
1. Enable State Encryption
remote_state {
backend = "s3"
config = {
encrypt = true
kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/..."
}
}2. Use IAM Roles for Authentication
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
assume_role {
role_arn = "arn:aws:iam::${local.account_id}:role/TerraformRole"
}
}
EOF
}3. Enable State Locking
remote_state {
backend = "s3"
config = {
# Preferred in recent Terraform versions (S3 native lock file)
use_lockfile = true
# Legacy locking for backwards compatibility only
dynamodb_table = "terraform-locks"
}
}4. Use Sensitive Variables
inputs = {
# Mark sensitive inputs
database_password = get_env("DB_PASSWORD") # Never hardcode
}Testing and Validation
1. Modern Terragrunt CLI (v0.93+)
Note: Terragrunt 0.93+ uses a redesigned CLI with significant changes:
run-allis deprecated → userun --allhclfmtis deprecated → usehcl fmtvalidate-inputsis deprecated → usehcl validate --inputsgraph-dependenciesis deprecated → usedag graph- The
--terragrunt-non-interactiveflag is no longer needed or supported
2. Validate Before Apply
# Format check (new syntax)
terragrunt hcl fmt --check
# Input validation (new in 0.93+)
terragrunt hcl validate --inputs
# Initialize (required for validation)
terragrunt init
# Validate Terraform configuration
terragrunt validate
# Generate plan
terragrunt plan3. Use run --all for Multi-Module Operations
Note:run-allis deprecated. Userun --allinstead.
# Validate all modules
terragrunt run --all validate
# Plan all modules
terragrunt run --all plan
# Apply all modules
terragrunt run --all apply
# With strict mode (errors on deprecated features)
terragrunt --strict-mode run --all plan
# Or via environment variable
TG_STRICT_MODE=true terragrunt run --all planPerformance Optimization
1. Use Shallow Dependencies
dependency "vpc" {
config_path = "../vpc"
# Only fetch specific outputs
mock_outputs_merge_strategy_with_state = "shallow"
}2. Parallelize Operations
# Run operations in parallel (new syntax)
terragrunt run --all apply --parallelism 4
# Legacy syntax (deprecated)
# terragrunt run-all apply --terragrunt-parallelism=43. Use Caching
# Cache downloaded modules
terraform {
source = "tfr:///terraform-aws-modules/vpc/aws?version=5.1.0"
}Troubleshooting Common Issues
Issue: Circular Dependencies
Symptom: "Cycle detected in dependency graph"
Solution:
- Review dependency chain
- Separate tightly coupled resources into single module
- Use data sources instead of dependencies where appropriate
Issue: State Locking Errors
Symptom: "Error acquiring the state lock"
Solution:
# Force unlock (use with caution)
terragrunt force-unlock <LOCK_ID>Issue: Module Not Found
Symptom: "Module not found"
Solution:
# Clear cache and reinitialize
rm -rf .terragrunt-cache
terragrunt initVersion Compatibility
Terragrunt Version Constraints
Specify minimum Terragrunt version:
# For new CLI features (recommended)
terragrunt_version_constraint = ">= 0.93.0"
# For backwards compatibility with older features
# terragrunt_version_constraint = ">= 0.48.0"Terraform Version Constraints
terraform_version_constraint = ">= 1.6.0, < 2.0.0"References
#!/usr/bin/env python3
"""
Terragrunt Custom Resource Detector
This script analyzes Terragrunt and Terraform configurations to identify:
- Custom Terraform providers (non-HashiCorp official)
- Custom modules (local or remote)
- Provider versions used
Outputs a report that can be used to guide documentation lookup via WebSearch or Context7.
"""
import re
import json
import argparse
import sys
from pathlib import Path
from typing import List
from collections import defaultdict
# Known official HashiCorp providers (comprehensive list)
OFFICIAL_PROVIDERS = {
# Core providers
'hashicorp/aws', 'hashicorp/azurerm', 'hashicorp/google', 'hashicorp/google-beta',
'hashicorp/kubernetes', 'hashicorp/kubernetes-alpha',
# Utility providers
'hashicorp/null', 'hashicorp/random', 'hashicorp/local', 'hashicorp/template',
'hashicorp/external', 'hashicorp/archive', 'hashicorp/http', 'hashicorp/time',
'hashicorp/tls', 'hashicorp/cloudinit',
# HashiCorp products
'hashicorp/vault', 'hashicorp/consul', 'hashicorp/nomad', 'hashicorp/tfe',
'hashicorp/hcp', 'hashicorp/boundary', 'hashicorp/waypoint',
# Kubernetes ecosystem
'hashicorp/helm',
# Cloud providers (additional)
'hashicorp/azuread', 'hashicorp/azurestack', 'hashicorp/googleworkspace',
'hashicorp/opc', 'hashicorp/oraclepaas',
# Infrastructure providers
'hashicorp/vsphere', 'hashicorp/dns', 'hashicorp/ad',
# Also accept short forms (without hashicorp/ prefix)
'aws', 'azurerm', 'google', 'google-beta', 'kubernetes', 'null', 'random',
'local', 'template', 'external', 'archive', 'http', 'time', 'tls',
'cloudinit', 'vault', 'consul', 'nomad', 'tfe', 'hcp', 'boundary',
'waypoint', 'helm', 'azuread', 'azurestack', 'googleworkspace', 'vsphere',
'dns', 'ad', 'opc', 'oraclepaas',
# OpenTofu registry format
'registry.opentofu.org/hashicorp/aws',
'registry.opentofu.org/hashicorp/azurerm',
'registry.opentofu.org/hashicorp/google',
'registry.opentofu.org/hashicorp/kubernetes',
}
# Paths that should never be scanned for source detection.
IGNORED_DIR_NAMES = {
'.git',
'.terraform',
'.terragrunt-cache',
'.idea',
'.venv',
'venv',
'__pycache__',
'node_modules',
}
class ResourceDetector:
def __init__(self, target_dir: str):
self.target_dir = Path(target_dir)
self.custom_providers = defaultdict(set)
self.custom_modules = defaultdict(list)
self.terragrunt_configs = []
def _is_ignored_path(self, path: Path) -> bool:
"""Return True when a file is inside ignored/generated directories."""
return any(part in IGNORED_DIR_NAMES for part in path.parts)
def find_hcl_files(self) -> List[Path]:
"""Find all .hcl and .tf files in the target directory."""
hcl_files = []
for ext in ['*.hcl', '*.tf']:
for file_path in self.target_dir.rglob(ext):
if self._is_ignored_path(file_path):
continue
hcl_files.append(file_path)
return sorted(hcl_files)
def _record_custom_provider(self, provider_source: str, provider_version: str) -> None:
"""Store custom provider in normalized form."""
normalized_source = provider_source.strip()
if not normalized_source:
return
version = provider_version.strip() if provider_version else "unspecified"
if normalized_source not in OFFICIAL_PROVIDERS:
self.custom_providers[normalized_source].add(version)
def _extract_required_providers(self, content: str) -> None:
"""Extract provider declarations from required_providers blocks."""
start_pattern = r'required_providers\s*{'
matches = list(re.finditer(start_pattern, content, re.MULTILINE))
for match in matches:
start_pos = match.end() - 1
block_content = self._extract_balanced_braces(content, start_pos)
if not block_content:
continue
provider_block_pattern = r'(\w+)\s*=\s*{'
for provider_match in re.finditer(provider_block_pattern, block_content):
provider_name = provider_match.group(1)
provider_start = provider_match.end() - 1
provider_body = self._extract_balanced_braces(block_content, provider_start)
if not provider_body:
continue
source_match = re.search(r'source\s*=\s*"([^"]+)"', provider_body)
version_match = re.search(r'version\s*=\s*"([^"]+)"', provider_body)
provider_source = source_match.group(1) if source_match else provider_name
provider_version = version_match.group(1) if version_match else "unspecified"
self._record_custom_provider(provider_source, provider_version)
def extract_providers(self, content: str, filepath: str) -> None:
"""Extract provider configurations from HCL content."""
self._extract_required_providers(content)
# Also look for standalone provider blocks
standalone_provider_pattern = r'provider\s+"(\w+)"\s*{([^}]+)}'
for match in re.finditer(standalone_provider_pattern, content, re.MULTILINE | re.DOTALL):
provider_name = match.group(1)
# Try to find version in the block
version_match = re.search(r'version\s*=\s*"([^"]+)"', match.group(2))
if version_match:
version = version_match.group(1)
self._record_custom_provider(provider_name, version)
# Check for providers in Terragrunt generate blocks
# Format: generate "name" { ... contents = <<EOT ... EOT } (supports custom delimiters)
generate_pattern = r'generate\s+"[^"]+"\s*{.*?contents\s*=\s*<<-?([A-Za-z0-9_]+)\n(.*?)\n\s*\1'
for match in re.finditer(generate_pattern, content, re.MULTILINE | re.DOTALL):
generated_content = match.group(2)
# Recursively extract providers from generated content
self._extract_providers_from_block(generated_content)
def _extract_providers_from_block(self, content: str) -> None:
"""Helper to extract providers from a content block."""
self._extract_required_providers(content)
def _extract_balanced_braces(self, content: str, start_pos: int) -> str:
"""Extract content within balanced braces starting from start_pos."""
if start_pos >= len(content) or content[start_pos] != '{':
return ""
depth = 0
for i in range(start_pos, len(content)):
if content[i] == '{':
depth += 1
elif content[i] == '}':
depth -= 1
if depth == 0:
return content[start_pos+1:i]
return content[start_pos+1:] # Return rest if unbalanced
def extract_modules(self, content: str, filepath: str) -> None:
"""Extract module configurations from HCL content."""
# Use balanced-brace extraction (same approach as _extract_required_providers) so
# that module blocks with nested objects before the source attribute are handled
# correctly. The old non-greedy regex stopped at the first closing brace it saw,
# which caused it to miss the source when any nested block appeared first.
module_start_pattern = r'module\s+"([^"]+)"\s*\{'
for match in re.finditer(module_start_pattern, content, re.MULTILINE):
module_name = match.group(1)
# match.end() points to the character after '{'; step back to land on '{'.
start_pos = match.end() - 1
block_content = self._extract_balanced_braces(content, start_pos)
if not block_content:
continue
source_match = re.search(r'source\s*=\s*"([^"]+)"', block_content)
if not source_match:
continue
source = source_match.group(1)
version_match = re.search(r'version\s*=\s*"([^"]+)"', block_content)
version = version_match.group(1) if version_match else "latest"
module_info = {
'name': module_name,
'source': source,
'version': version,
'file': str(filepath),
'type': self._categorize_module_source(source)
}
# Only add if it's a custom or remote module (not local relative paths)
if module_info['type'] in ['git', 'registry', 'http', 'custom']:
self.custom_modules[source].append(module_info)
# Also check for Terragrunt-style terraform blocks.
# Format: terraform { source = "tfr://..." }
# Use balanced-brace extraction here too so that terraform blocks with nested
# extra_arguments or hook sub-blocks are parsed correctly regardless of attribute
# order (e.g., root.hcl terraform blocks that have no source should be skipped).
tf_start_pattern = r'terraform\s*\{'
for match in re.finditer(tf_start_pattern, content, re.MULTILINE):
start_pos = match.end() - 1
block_content = self._extract_balanced_braces(content, start_pos)
if not block_content:
continue
source_match = re.search(r'source\s*=\s*"([^"]+)"', block_content)
if not source_match:
continue
source = source_match.group(1)
# Parse version from source string (e.g., tfr:///.../module?version=1.0.0)
version = "latest"
version_in_source = re.search(r'[?&]version=([^&"]+)', source)
if version_in_source:
version = version_in_source.group(1)
# Parse ref from git sources (e.g., git::...?ref=v1.0.0)
ref_in_source = re.search(r'[?&]ref=([^&"]+)', source)
if ref_in_source:
version = ref_in_source.group(1)
# Clean the source for categorization
clean_source = re.sub(r'\?.*$', '', source)
module_info = {
'name': Path(filepath).parent.name, # Use directory name as module name
'source': clean_source,
'version': version,
'file': str(filepath),
'type': self._categorize_module_source(clean_source)
}
if module_info['type'] in ['git', 'registry', 'http', 'custom', 'terragrunt']:
self.custom_modules[clean_source].append(module_info)
def _categorize_module_source(self, source: str) -> str:
"""Categorize the module source type."""
if source.startswith('./') or source.startswith('../'):
return 'local'
elif source.startswith('tfr:///'):
# Terragrunt registry format
return 'terragrunt'
elif source.startswith('git::') or 'github.com' in source or 'gitlab.com' in source:
return 'git'
elif source.startswith('http://') or source.startswith('https://'):
return 'http'
elif '/' in source and not source.startswith('.'):
# Check if this looks like a provider source (org/name) vs module (org/name/provider)
# Provider sources have exactly 2 path components (e.g., hashicorp/aws, datadog/datadog)
# Module sources have 3+ components (e.g., terraform-aws-modules/vpc/aws)
parts = source.split('/')
if len(parts) == 2:
# This looks like a provider source, not a module
# Filter these out as they're detected separately in extract_providers
return 'provider'
else:
# Likely Terraform Registry format (e.g., terraform-aws-modules/vpc/aws)
return 'registry'
else:
return 'custom'
def analyze_directory(self) -> None:
"""Analyze all HCL files in the target directory."""
hcl_files = self.find_hcl_files()
if not hcl_files:
print(f"No .hcl or .tf files found in {self.target_dir}", file=sys.stderr)
return
for filepath in hcl_files:
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
self.extract_providers(content, filepath)
self.extract_modules(content, filepath)
except Exception as e:
print(f"Error reading {filepath}: {e}", file=sys.stderr)
def generate_report(self, output_format: str = 'text') -> str:
"""Generate a report of custom resources found."""
if output_format == 'json':
return self._generate_json_report()
else:
return self._generate_text_report()
def _generate_json_report(self) -> str:
"""Generate JSON format report."""
report = {
'custom_providers': {
provider: sorted(list(versions))
for provider, versions in sorted(self.custom_providers.items())
},
'custom_modules': {
source: modules
for source, modules in sorted(self.custom_modules.items())
}
}
return json.dumps(report, indent=2)
def _generate_text_report(self) -> str:
"""Generate human-readable text report."""
lines = []
lines.append("=" * 80)
lines.append("Terragrunt Custom Resource Detection Report")
lines.append("=" * 80)
lines.append("")
# Custom Providers Section
if self.custom_providers:
lines.append("CUSTOM PROVIDERS DETECTED:")
lines.append("-" * 80)
for provider, versions in sorted(self.custom_providers.items()):
lines.append(f"\nProvider: {provider}")
lines.append(f" Versions: {', '.join(sorted(versions))}")
lines.append(f" → Action: Resolve with Context7 library lookup:")
lines.append(f" mcp__context7__resolve-library-id (libraryName: \"{provider} terraform provider\")")
lines.append(f" mcp__context7__query-docs (query: \"authentication and configuration\")")
lines.append(f" or search web: '{provider} terraform provider documentation'")
lines.append("")
else:
lines.append("CUSTOM PROVIDERS: None detected")
lines.append("")
# Custom Modules Section
if self.custom_modules:
lines.append("CUSTOM MODULES DETECTED:")
lines.append("-" * 80)
for source, modules in sorted(self.custom_modules.items()):
module_info = modules[0] # Take first occurrence for details
lines.append(f"\nModule Source: {source}")
lines.append(f" Type: {module_info['type']}")
lines.append(f" Version: {module_info['version']}")
lines.append(f" Used in: {module_info['file']}")
lines.append(f" Used {len(modules)} time(s)")
# Provide search guidance based on module type
if module_info['type'] == 'git':
clean_source = source.replace('git::', '')
lines.append(f" → Action: Search repository docs for '{clean_source}'")
elif module_info['type'] == 'registry' or module_info['type'] == 'terragrunt':
# Clean up tfr:/// prefix for registry lookup
clean_source = source.replace('tfr:///', '')
registry_source = clean_source.split('//')[0]
lines.append(f" → Action: Visit https://registry.terraform.io/modules/{registry_source}")
lines.append(f" and resolve with Context7: \"{registry_source}\"")
else:
lines.append(f" → Action: Search for documentation related to this module source")
lines.append("")
else:
lines.append("CUSTOM MODULES: None detected")
lines.append("")
# Summary
lines.append("=" * 80)
lines.append("SUMMARY")
lines.append("=" * 80)
lines.append(f"Custom Providers: {len(self.custom_providers)}")
lines.append(f"Custom Modules: {len(self.custom_modules)}")
lines.append("")
if self.custom_providers or self.custom_modules:
lines.append("RECOMMENDED ACTIONS:")
lines.append("1. Use WebSearch to look up documentation for each custom resource")
lines.append("2. Pay attention to version compatibility")
lines.append("3. Review provider/module documentation for required configuration")
lines.append("4. Check for any known issues or breaking changes")
else:
lines.append("No custom providers or modules detected.")
lines.append("All resources appear to be standard HashiCorp providers or local modules.")
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description='Detect custom providers and modules in Terragrunt/Terraform configurations'
)
parser.add_argument(
'directory',
nargs='?',
default='.',
help='Directory to analyze (default: current directory)'
)
parser.add_argument(
'--format',
choices=['text', 'json'],
default='text',
help='Output format (default: text)'
)
args = parser.parse_args()
detector = ResourceDetector(args.directory)
detector.analyze_directory()
report = detector.generate_report(args.format)
print(report)
if __name__ == '__main__':
main()
#!/usr/bin/env bash
#
# Deterministic CI entrypoint for terragrunt-validator.
# Runs syntax checks plus regression tests that do not depend on cloud access.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
readonly SKILL_DIR
VALIDATOR_SCRIPT="$SKILL_DIR/scripts/validate_terragrunt.sh"
DETECTOR_SCRIPT="$SKILL_DIR/scripts/detect_custom_resources.py"
SHELL_REGRESSION_TEST="$SKILL_DIR/test/test_validate_terragrunt.sh"
PYTHON_REGRESSION_TEST="$SKILL_DIR/test/test_detect_custom_resources.py"
SELF_SCRIPT="$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}")"
usage() {
cat <<'EOF'
Usage: run_ci_checks.sh [OPTIONS]
Deterministic CI checks for terragrunt-validator.
Options:
--require-shellcheck Fail when shellcheck is unavailable.
--skip-shellcheck Skip shellcheck stage.
-h, --help Show this help message.
Environment:
CI=true|1 Defaults to --require-shellcheck unless overridden.
EOF
}
is_true() {
local value="${1:-}"
[[ "$value" == "true" || "$value" == "1" ]]
}
main() {
local require_shellcheck=0
local skip_shellcheck=0
local shellcheck_overridden=0
while [[ $# -gt 0 ]]; do
case "$1" in
--require-shellcheck)
require_shellcheck=1
skip_shellcheck=0
shellcheck_overridden=1
;;
--skip-shellcheck)
skip_shellcheck=1
require_shellcheck=0
shellcheck_overridden=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option '$1'" >&2
usage
exit 1
;;
esac
shift
done
if [[ "$shellcheck_overridden" -eq 0 ]] && is_true "${CI:-}"; then
require_shellcheck=1
fi
if [[ "$skip_shellcheck" -eq 1 && "$require_shellcheck" -eq 1 ]]; then
echo "Error: --skip-shellcheck and --require-shellcheck cannot be combined." >&2
exit 1
fi
export LC_ALL=C
export LANG=C
export TZ=UTC
echo "[1/5] bash syntax checks"
bash -n "$VALIDATOR_SCRIPT" "$SHELL_REGRESSION_TEST" "$SELF_SCRIPT"
echo "[2/5] python syntax checks"
python3 -m py_compile "$DETECTOR_SCRIPT" "$PYTHON_REGRESSION_TEST"
echo "[3/5] python regression tests"
python3 "$PYTHON_REGRESSION_TEST"
echo "[4/5] shell regression tests"
bash "$SHELL_REGRESSION_TEST"
echo "[5/5] shellcheck"
if [[ "$skip_shellcheck" -eq 1 ]]; then
echo "ShellCheck: SKIP (--skip-shellcheck)"
elif command -v shellcheck >/dev/null 2>&1; then
shellcheck "$VALIDATOR_SCRIPT" "$SHELL_REGRESSION_TEST" "$SELF_SCRIPT"
echo "ShellCheck: PASS"
elif [[ "$require_shellcheck" -eq 1 ]]; then
echo "ShellCheck: required but not installed" >&2
exit 1
else
echo "ShellCheck: SKIP (not installed; use --require-shellcheck to enforce)"
fi
echo "PASS: terragrunt-validator CI checks"
}
main "$@"
#!/bin/bash
# Terragrunt Validation Script
# This script performs comprehensive validation of Terragrunt configurations including:
# - HCL formatting checks
# - HCL input validation (new in 0.93+)
# - Terragrunt validation
# - Terraform validation
# - Linting with tflint
# - Security scanning with Trivy (preferred) or tfsec (legacy)
# - Dependency graph validation
#
# Designed for Terragrunt 0.93+ with the new CLI redesign
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
TARGET_DIR_INPUT="${1:-.}"
# Convert to absolute path when target exists, keep input value otherwise
# so main() can print a friendly, consistent error message.
if [[ -d "$TARGET_DIR_INPUT" ]]; then
TARGET_DIR=$(cd "$TARGET_DIR_INPUT" && pwd)
else
TARGET_DIR="$TARGET_DIR_INPUT"
fi
SKIP_PLAN="${SKIP_PLAN:-false}"
SKIP_SECURITY="${SKIP_SECURITY:-false}"
SKIP_LINT="${SKIP_LINT:-false}"
SKIP_INPUT_VALIDATION="${SKIP_INPUT_VALIDATION:-false}"
SKIP_INIT="${SKIP_INIT:-false}"
SKIP_BACKEND_INIT="${SKIP_BACKEND_INIT:-false}"
SOFT_FAIL_SECURITY="${SOFT_FAIL_SECURITY:-false}"
# Security scanner preference (trivy, tfsec, checkov, or auto)
SECURITY_SCANNER="${SECURITY_SCANNER:-auto}"
# Build strict mode flag
STRICT_FLAG=""
if [[ "${TG_STRICT_MODE:-false}" == "true" ]]; then
STRICT_FLAG="--strict-mode"
fi
print_header() {
echo -e "\n${BLUE}===================================${NC}"
echo -e "${BLUE}$1${NC}"
echo -e "${BLUE}===================================${NC}\n"
}
print_success() {
echo -e "${GREEN}✓ $1${NC}"
}
print_error() {
echo -e "${RED}✗ $1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠ $1${NC}"
}
print_info() {
echo -e "${BLUE}ℹ $1${NC}"
}
# Detect execution mode from Terragrunt files in target directory.
# Modes:
# - multi: nested terragrunt units exist
# - single: current dir is a single Terragrunt unit
# - root-only: root.hcl exists but no unit in current directory
# - none: no recognizable Terragrunt config found
detect_target_mode() {
local has_direct_terragrunt=false
local has_nested_terragrunt=false
if [[ -f "$TARGET_DIR/terragrunt.hcl" || -f "$TARGET_DIR/terragrunt.stack.hcl" ]]; then
has_direct_terragrunt=true
fi
if find "$TARGET_DIR" -mindepth 2 -type f \
\( -name "terragrunt.hcl" -o -name "terragrunt.stack.hcl" \) \
! -path "*/.terragrunt-cache/*" | grep -q .; then
has_nested_terragrunt=true
fi
if [[ "$has_nested_terragrunt" == "true" ]]; then
echo "multi"
elif [[ "$has_direct_terragrunt" == "true" ]]; then
echo "single"
elif [[ -f "$TARGET_DIR/root.hcl" ]]; then
echo "root-only"
else
echo "none"
fi
}
# Check if required tools are installed
check_dependencies() {
print_header "Checking Dependencies"
local missing_tools=()
if ! command -v terragrunt &> /dev/null; then
missing_tools+=("terragrunt")
else
local tg_version
tg_version=$(terragrunt --version | head -n1)
print_success "terragrunt $tg_version"
# Check if version is >= 0.93 for new CLI
if [[ "$tg_version" =~ v0\.([0-9]+) ]]; then
local minor_version="${BASH_REMATCH[1]}"
if (( minor_version < 93 )); then
print_warning "Terragrunt version < 0.93 detected. Some new CLI features may not work."
print_info "Consider upgrading to 0.93+ for best compatibility."
fi
fi
fi
if ! command -v terraform &> /dev/null; then
if command -v tofu &> /dev/null; then
print_success "opentofu $(tofu version -json 2>/dev/null | jq -r '.terraform_version' 2>/dev/null || tofu --version | head -n1)"
else
missing_tools+=("terraform or opentofu")
fi
else
print_success "terraform $(terraform version -json 2>/dev/null | jq -r '.terraform_version' 2>/dev/null || terraform --version | head -n1)"
fi
if [[ "$SKIP_LINT" != "true" ]] && ! command -v tflint &> /dev/null; then
print_warning "tflint not found - skipping lint checks"
SKIP_LINT=true
elif [[ "$SKIP_LINT" != "true" ]]; then
print_success "tflint $(tflint --version | head -n1)"
fi
# Check for security scanners
if [[ "$SKIP_SECURITY" != "true" ]]; then
local found_scanner=false
if [[ "$SECURITY_SCANNER" == "auto" ]] || [[ "$SECURITY_SCANNER" == "trivy" ]]; then
if command -v trivy &> /dev/null; then
print_success "trivy $(trivy --version 2>&1 | head -n1)"
SECURITY_SCANNER="trivy"
found_scanner=true
fi
fi
if [[ "$found_scanner" == "false" ]] && { [[ "$SECURITY_SCANNER" == "auto" ]] || [[ "$SECURITY_SCANNER" == "checkov" ]]; }; then
if command -v checkov &> /dev/null; then
print_success "checkov $(checkov --version 2>&1 | head -n1)"
SECURITY_SCANNER="checkov"
found_scanner=true
fi
fi
if [[ "$found_scanner" == "false" ]] && { [[ "$SECURITY_SCANNER" == "auto" ]] || [[ "$SECURITY_SCANNER" == "tfsec" ]]; }; then
if command -v tfsec &> /dev/null; then
print_warning "tfsec found but is deprecated - consider migrating to Trivy"
print_success "tfsec $(tfsec --version 2>&1 | head -n1)"
SECURITY_SCANNER="tfsec"
found_scanner=true
fi
fi
if [[ "$found_scanner" == "false" ]]; then
print_warning "No security scanner found (trivy, checkov, or tfsec) - skipping security checks"
print_info "Install trivy: brew install trivy (macOS) or see https://trivy.dev"
SKIP_SECURITY=true
fi
fi
if [ ${#missing_tools[@]} -ne 0 ]; then
print_error "Missing required tools: ${missing_tools[*]}"
echo -e "\nInstallation instructions:"
for tool in "${missing_tools[@]}"; do
case $tool in
terragrunt)
echo " - terragrunt: https://terragrunt.gruntwork.io/docs/getting-started/install/"
;;
"terraform or opentofu")
echo " - terraform: https://developer.hashicorp.com/terraform/downloads"
echo " - opentofu: https://opentofu.org/docs/intro/install/"
;;
esac
done
exit 1
fi
}
# Format check
format_check() {
print_header "HCL Format Check"
cd "$TARGET_DIR"
# Try new command first, fall back to old if needed
if terragrunt hcl fmt --check 2>/dev/null; then
print_success "All HCL files are properly formatted"
elif terragrunt hcl format --check 2>/dev/null; then
print_success "All HCL files are properly formatted"
else
print_error "HCL files are not properly formatted"
echo -e "\nRun 'terragrunt hcl fmt' to fix formatting issues"
return 1
fi
}
# Validate HCL inputs (new in Terragrunt 0.93+)
validate_inputs() {
print_header "HCL Input Validation"
cd "$TARGET_DIR"
local mode
mode=$(detect_target_mode)
local -a validate_cmd=()
if [[ "$mode" == "multi" ]]; then
validate_cmd=(terragrunt hcl validate --inputs --all)
print_info "Running input validation across all units..."
elif [[ "$mode" == "single" ]]; then
validate_cmd=(terragrunt hcl validate --inputs)
print_info "Running input validation on single unit..."
elif [[ "$mode" == "root-only" ]]; then
print_warning "No terragrunt.hcl in current directory for input validation"
print_info "Root-only directory detected (root.hcl). Run this script in a unit directory or keep multi-unit layout."
return 0
else
print_warning "No terragrunt.hcl files found for input validation"
return 0
fi
# Run the validation command
local output
if output=$("${validate_cmd[@]}" 2>&1); then
print_success "All inputs validated successfully"
else
local exit_code=$?
# Check if it's a "command not found" error (127) or actual validation failure
if [[ $exit_code -eq 127 ]]; then
print_warning "Input validation command not available"
print_info "This feature requires Terragrunt 0.93+"
elif echo "$output" | grep -q "unknown command\|unknown flag"; then
print_warning "Input validation not supported in this Terragrunt version"
print_info "This feature requires Terragrunt 0.93+"
else
# Show the actual error for debugging
echo "$output" | head -20
print_warning "Input validation completed with warnings or errors"
# Don't fail the entire validation for input warnings
fi
fi
}
# Validate Terragrunt configuration syntax
# Uses 'terragrunt hcl validate' which checks HCL structure without requiring
# Terraform providers or remote credentials. This is distinct from format_check()
# (whitespace/indentation) and validate_inputs() (variable alignment).
validate_terragrunt() {
print_header "Terragrunt Configuration Check"
cd "$TARGET_DIR"
# Check if .hcl files exist (exclude cache dirs to avoid false positives)
if ! find . -name "*.hcl" -type f ! -path "*/.terragrunt-cache/*" | grep -q .; then
print_error "No .hcl files found in $TARGET_DIR"
return 1
fi
local mode
mode=$(detect_target_mode)
local -a validate_cmd=(terragrunt hcl validate)
case "$mode" in
multi)
validate_cmd+=(--all)
print_info "Multi-unit directory detected, validating all units..."
;;
single)
print_info "Single-unit directory detected, validating current unit..."
;;
root-only)
print_warning "No terragrunt.hcl in current directory for syntax validation"
print_info "Root-only directory detected (root.hcl). Run this script in a unit directory or keep multi-unit layout."
return 0
;;
*)
print_error "No Terragrunt configuration files found in $TARGET_DIR"
return 1
;;
esac
local output
if output=$("${validate_cmd[@]}" 2>&1); then
print_success "Terragrunt HCL syntax is valid"
else
local hcl_exit=$?
# Compatibility fallback for Terragrunt variants where hcl validate
# exists but does not accept --all.
if [[ "$mode" == "multi" ]] && echo "$output" | grep -qi "unknown flag.*--all"; then
print_warning "Terragrunt does not support 'hcl validate --all'; falling back to single-command validation"
if output=$(terragrunt hcl validate 2>&1); then
print_success "Terragrunt HCL syntax is valid (fallback path)"
return 0
fi
hcl_exit=$?
fi
# Command not found (127) or unknown command means pre-0.93 Terragrunt.
# Fall back to format check as a best-effort proxy — it at least catches
# structural HCL errors even though it is not a pure syntax validator.
if [[ $hcl_exit -eq 127 ]] || echo "$output" | grep -q "unknown command"; then
print_warning "terragrunt hcl validate not available (requires 0.93+), using format check as proxy"
if terragrunt hcl fmt --check > /dev/null 2>&1 || terragrunt hcl format --check > /dev/null 2>&1; then
print_success "Terragrunt configuration syntax appears valid (format check passed)"
else
print_warning "Configuration files may have formatting or syntax issues"
fi
else
print_error "Terragrunt HCL syntax validation failed"
echo "$output" | head -20
return 1
fi
fi
}
# Validate Terraform configuration
validate_terraform() {
print_header "Terraform Validation"
cd "$TARGET_DIR"
local mode
mode=$(detect_target_mode)
# For multi-unit directories, run init+validate across all units.
if [[ "$mode" == "multi" ]]; then
print_info "Multi-unit directory detected, using 'run --all validate'"
if [[ "$SKIP_INIT" != "true" ]]; then
local -a multi_init_cmd=(terragrunt)
if [[ -n "$STRICT_FLAG" ]]; then
multi_init_cmd+=("$STRICT_FLAG")
fi
multi_init_cmd+=(run --all init)
if [[ "$SKIP_BACKEND_INIT" == "true" ]]; then
multi_init_cmd+=("-backend=false")
print_info "Backend initialization disabled for multi-unit init"
fi
if ! "${multi_init_cmd[@]}" 2>&1; then
print_error "Terraform initialization failed across one or more units"
return 1
fi
else
print_info "Skipping terraform init step (SKIP_INIT=true)"
fi
local -a multi_validate_cmd=(terragrunt)
if [[ -n "$STRICT_FLAG" ]]; then
multi_validate_cmd+=("$STRICT_FLAG")
fi
multi_validate_cmd+=(run --all validate)
if "${multi_validate_cmd[@]}" 2>&1; then
print_success "Terraform configuration is valid across all units"
else
print_error "Terraform validation failed across one or more units"
return 1
fi
return 0
fi
# For single unit directories
if [[ "$mode" == "single" ]]; then
# Initialize if needed unless explicitly skipped.
if [[ "$SKIP_INIT" != "true" ]] && [ ! -d ".terraform" ] && [ ! -d ".terragrunt-cache" ]; then
echo "Initializing Terraform..."
local -a single_init_cmd=(terragrunt)
if [[ -n "$STRICT_FLAG" ]]; then
single_init_cmd+=("$STRICT_FLAG")
fi
single_init_cmd+=(init)
if [[ "$SKIP_BACKEND_INIT" == "true" ]]; then
single_init_cmd+=("-backend=false")
print_info "Backend initialization disabled for single-unit init"
fi
if ! "${single_init_cmd[@]}" 2>&1; then
print_error "Terraform initialization failed"
return 1
fi
elif [[ "$SKIP_INIT" == "true" ]]; then
print_info "Skipping terraform init step (SKIP_INIT=true)"
fi
local -a single_validate_cmd=(terragrunt)
if [[ -n "$STRICT_FLAG" ]]; then
single_validate_cmd+=("$STRICT_FLAG")
fi
single_validate_cmd+=(validate)
if "${single_validate_cmd[@]}" 2>&1; then
print_success "Terraform configuration is valid"
else
print_error "Terraform validation failed"
return 1
fi
elif [[ "$mode" == "root-only" ]]; then
print_warning "No terragrunt.hcl found for Terraform validation"
print_info "Root-only directory detected (root.hcl). Run this script from a unit directory or a multi-unit parent."
else
print_error "No Terragrunt configuration files found in $TARGET_DIR"
return 1
fi
}
# Run tflint
run_tflint() {
print_header "TFLint Analysis"
cd "$TARGET_DIR"
# Initialize tflint if .tflint.hcl exists
if [ -f ".tflint.hcl" ]; then
tflint --init 2>/dev/null || true
fi
if tflint --recursive 2>&1; then
print_success "No linting issues found"
else
print_error "Linting issues detected"
return 1
fi
}
# Run security scan with the available scanner
run_security_scan() {
print_header "Security Scan ($SECURITY_SCANNER)"
cd "$TARGET_DIR"
case "$SECURITY_SCANNER" in
trivy)
print_info "Using Trivy (recommended) for security scanning"
if trivy config . --severity HIGH,CRITICAL --exit-code 1 2>&1; then
print_success "No critical security issues found"
else
if [[ "$SOFT_FAIL_SECURITY" == "true" ]]; then
print_warning "Security issues detected but not failing build (SOFT_FAIL_SECURITY=true)"
else
print_error "Security issues detected"
return 1
fi
fi
;;
checkov)
print_info "Using Checkov for security scanning"
if checkov -d . --framework terraform 2>&1; then
print_success "No critical security issues found"
else
if [[ "$SOFT_FAIL_SECURITY" == "true" ]]; then
print_warning "Security issues detected but not failing build (SOFT_FAIL_SECURITY=true)"
else
print_error "Security issues detected"
return 1
fi
fi
;;
tfsec)
print_warning "Using tfsec (deprecated) - consider migrating to Trivy"
print_info "Migration guide: https://github.com/aquasecurity/tfsec/blob/master/tfsec-to-trivy-migration-guide.md"
if tfsec . 2>&1; then
print_success "No critical security issues found"
else
if [[ "$SOFT_FAIL_SECURITY" == "true" ]]; then
print_warning "Security issues detected but not failing build (SOFT_FAIL_SECURITY=true)"
else
print_error "Security issues detected"
return 1
fi
fi
;;
*)
print_warning "Unknown security scanner: $SECURITY_SCANNER"
return 1
;;
esac
}
# Generate and validate dependency graph
validate_dependencies() {
print_header "Dependency Graph Validation"
cd "$TARGET_DIR"
# Check if dependencies are properly configured
if find . -name "*.hcl" -type f ! -path "*/.terragrunt-cache/*" -exec grep -l "dependency" {} \; | grep -q .; then
print_success "Dependency blocks found in configuration"
# Try to generate DAG graph (new in 0.93+)
if terragrunt dag graph > /dev/null 2>&1; then
print_success "Dependency graph is valid (no cycles detected)"
else
print_info "Could not generate dependency graph"
print_info "Use 'terragrunt run --all plan' to validate dependency resolution"
fi
else
print_success "No dependencies to validate"
fi
}
# Dry-run plan
run_plan() {
print_header "Terragrunt Plan (Dry-Run)"
cd "$TARGET_DIR"
if [[ -n "$STRICT_FLAG" ]]; then
print_info "Running with strict mode enabled"
fi
local mode
mode=$(detect_target_mode)
if [[ "$mode" == "multi" ]]; then
echo "Running terragrunt run --all plan..."
local -a plan_cmd=(terragrunt)
if [[ -n "$STRICT_FLAG" ]]; then
plan_cmd+=("$STRICT_FLAG")
fi
plan_cmd+=(run --all plan)
if "${plan_cmd[@]}" 2>&1; then
print_success "Plan generated successfully across all units"
else
print_error "Plan generation failed across one or more units"
return 1
fi
elif [[ "$mode" == "single" ]]; then
echo "Running terragrunt plan..."
local -a plan_cmd=(terragrunt)
if [[ -n "$STRICT_FLAG" ]]; then
plan_cmd+=("$STRICT_FLAG")
fi
plan_cmd+=(plan -out=tfplan)
if "${plan_cmd[@]}" 2>&1; then
print_success "Plan generated successfully"
echo -e "\nTo review the plan, run:"
echo " terragrunt show tfplan"
else
print_error "Plan generation failed"
return 1
fi
else
print_warning "No unit terragrunt.hcl found for plan step"
print_info "Skipping plan in non-unit directory"
fi
}
# Main execution
main() {
echo -e "${BLUE}╔═══════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Terragrunt Validation Suite ║${NC}"
echo -e "${BLUE}║ (Designed for Terragrunt 0.93+) ║${NC}"
echo -e "${BLUE}╚═══════════════════════════════════════╝${NC}"
if [[ -n "$STRICT_FLAG" ]]; then
print_info "Strict mode enabled - deprecated features will cause errors"
fi
if [[ ! -d "$TARGET_DIR" ]]; then
print_error "Target directory does not exist: $TARGET_DIR"
exit 1
fi
check_dependencies
local exit_code=0
# Run all checks
format_check || exit_code=$?
if [[ "$SKIP_INPUT_VALIDATION" != "true" ]]; then
validate_inputs || true # Don't fail on input validation (may not be available)
fi
validate_terragrunt || exit_code=$?
validate_terraform || exit_code=$?
if [[ "$SKIP_LINT" != "true" ]]; then
run_tflint || exit_code=$?
fi
if [[ "$SKIP_SECURITY" != "true" ]]; then
run_security_scan || exit_code=$?
fi
validate_dependencies || exit_code=$?
if [[ "$SKIP_PLAN" != "true" ]]; then
run_plan || exit_code=$?
fi
# Summary
print_header "Validation Summary"
if [ $exit_code -eq 0 ]; then
print_success "All validation checks passed!"
else
print_error "Some validation checks failed. Please review the output above."
fi
exit $exit_code
}
# Show usage if --help is passed
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
echo "Usage: $0 [TARGET_DIR]"
echo ""
echo "Validates Terragrunt configurations with comprehensive checks."
echo "Designed for Terragrunt 0.93+ with the new CLI redesign."
echo ""
echo "Options:"
echo " TARGET_DIR Directory containing Terragrunt files (default: current directory)"
echo ""
echo "Environment Variables:"
echo " SKIP_PLAN=true Skip the terragrunt plan step"
echo " SKIP_SECURITY=true Skip security scanning"
echo " SKIP_LINT=true Skip linting with tflint"
echo " SKIP_INPUT_VALIDATION=true Skip HCL input validation"
echo " SKIP_INIT=true Skip terraform init step before validate"
echo " SKIP_BACKEND_INIT=true Run terraform init with -backend=false"
echo " SOFT_FAIL_SECURITY=true Do not fail on scanner findings"
echo " SECURITY_SCANNER=X Force specific scanner: trivy, checkov, tfsec, or auto (default)"
echo " TG_STRICT_MODE=true Enable Terragrunt strict mode (errors on deprecated features)"
echo ""
echo "Security Scanners (in order of preference):"
echo " trivy - Recommended, actively maintained (replaces tfsec)"
echo " checkov - Alternative with 750+ built-in policies"
echo " tfsec - Legacy, deprecated (merged into Trivy)"
echo ""
echo "Examples:"
echo " $0 # Validate current directory"
echo " $0 ./infrastructure # Validate specific directory"
echo " SKIP_PLAN=true $0 # Skip plan generation"
echo " SECURITY_SCANNER=trivy $0 # Force Trivy for security"
echo " SKIP_BACKEND_INIT=true $0 # Avoid remote backend auth during init"
echo " TG_STRICT_MODE=true $0 # Enable strict mode"
exit 0
fi
main
# Common variables shared across all environments
locals {
# AWS Region
region = "us-east-1"
# Availability zones
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
# VPC CIDR blocks per environment
vpc_cidrs = {
dev = "10.0.0.0/16"
staging = "10.1.0.0/16"
prod = "10.2.0.0/16"
}
# Common resource naming convention
name_prefix = "myapp"
# Backup retention periods
backup_retention = {
dev = 7
staging = 14
prod = 30
}
# Instance sizes per environment
instance_sizes = {
dev = {
database = "db.t3.micro"
cache = "cache.t3.micro"
app = "t3.small"
}
staging = {
database = "db.t3.small"
cache = "cache.t3.small"
app = "t3.medium"
}
prod = {
database = "db.r6g.large"
cache = "cache.r6g.large"
app = "t3.large"
}
}
# Multi-AZ configuration
multi_az = {
dev = false
staging = false
prod = true
}
# High availability configuration
ha_config = {
dev = {
min_size = 1
max_size = 2
desired_size = 1
}
staging = {
min_size = 1
max_size = 3
desired_size = 2
}
prod = {
min_size = 2
max_size = 10
desired_size = 3
}
}
}
# Application ECS Configuration for Development
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "tfr:///terraform-aws-modules/ecs/aws?version=5.7.0"
}
# Dependencies
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "vpc-mock-123456"
private_subnet_ids = ["subnet-mock-1", "subnet-mock-2"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
}
dependency "database" {
config_path = "../database"
mock_outputs = {
db_instance_endpoint = "mock-db-endpoint.rds.amazonaws.com:5432"
db_instance_name = "myappdb"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
}
dependency "cache" {
config_path = "../cache"
mock_outputs = {
endpoint = "mock-redis-endpoint.cache.amazonaws.com:6379"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
}
locals {
common = read_terragrunt_config(find_in_parent_folders("common.hcl"))
}
inputs = {
cluster_name = "${local.common.locals.name_prefix}-dev-cluster"
cluster_configuration = {
execute_command_configuration = {
logging = "OVERRIDE"
log_configuration = {
cloud_watch_log_group_name = "/aws/ecs/${local.common.locals.name_prefix}-dev"
}
}
}
# Fargate capacity providers
fargate_capacity_providers = {
FARGATE = {
default_capacity_provider_strategy = {
weight = 50
base = 20
}
}
FARGATE_SPOT = {
default_capacity_provider_strategy = {
weight = 50
}
}
}
# Service configuration
services = {
myapp = {
cpu = 512
memory = 1024
container_definitions = {
app = {
cpu = 512
memory = 1024
essential = true
image = "nginx:latest"
port_mappings = [
{
name = "http"
containerPort = 80
hostPort = 80
protocol = "tcp"
}
]
environment = [
{
name = "DATABASE_ENDPOINT"
value = dependency.database.outputs.db_instance_endpoint
},
{
name = "REDIS_ENDPOINT"
value = dependency.cache.outputs.endpoint
},
{
name = "ENVIRONMENT"
value = "dev"
}
]
readonly_root_filesystem = false
}
}
subnet_ids = dependency.vpc.outputs.private_subnet_ids
security_group_rules = {
ingress_http = {
type = "ingress"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["10.0.0.0/16"]
}
}
}
}
tags = {
Module = "app"
}
}
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
backend "s3" {
bucket = "terraform-state-123456789012"
dynamodb_table = "terraform-locks"
encrypt = true
key = "dev/terraform.tfstate"
region = "us-east-1"
}
}
# ElastiCache Redis Configuration for Development
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
# Using a Git module source to test custom module detection
source = "git::https://github.com/cloudposse/terraform-aws-elasticache-redis.git?ref=0.52.0"
}
# Dependency on VPC
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "vpc-mock-123456"
private_subnet_ids = ["subnet-mock-1", "subnet-mock-2", "subnet-mock-3"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
}
locals {
common = read_terragrunt_config(find_in_parent_folders("common.hcl"))
}
inputs = {
name = "${local.common.locals.name_prefix}-dev-redis"
namespace = "myapp"
stage = "dev"
vpc_id = dependency.vpc.outputs.vpc_id
subnets = dependency.vpc.outputs.private_subnet_ids
cluster_size = 1
instance_type = "cache.t3.micro"
engine_version = "7.0"
family = "redis7"
at_rest_encryption_enabled = true
transit_encryption_enabled = true
automatic_failover_enabled = false
multi_az_enabled = false
parameter = [
{
name = "maxmemory-policy"
value = "allkeys-lru"
}
]
tags = {
Module = "cache"
}
}
# RDS Database Configuration for Development
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "tfr:///terraform-aws-modules/rds/aws?version=6.3.0"
}
# Dependency on VPC
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "vpc-mock-123456"
database_subnet_ids = ["subnet-mock-1", "subnet-mock-2", "subnet-mock-3"]
database_subnet_group_name = "mock-subnet-group"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
mock_outputs_merge_strategy_with_state = "shallow"
}
locals {
common = read_terragrunt_config(find_in_parent_folders("common.hcl"))
db_name = "myappdb"
}
inputs = {
identifier = "${local.common.locals.name_prefix}-dev-db"
engine = "postgres"
engine_version = "15.4"
family = "postgres15"
major_engine_version = "15"
instance_class = local.common.locals.instance_sizes["dev"]["database"]
allocated_storage = 20
max_allocated_storage = 100
db_name = local.db_name
username = "dbadmin"
port = 5432
# Use VPC outputs
db_subnet_group_name = dependency.vpc.outputs.database_subnet_group_name
vpc_security_group_ids = [] # Should create security group
multi_az = false
deletion_protection = false
backup_retention_period = 7
skip_final_snapshot = true
enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
create_cloudwatch_log_group = true
parameters = [
{
name = "autovacuum"
value = 1
},
{
name = "client_encoding"
value = "utf8"
}
]
tags = {
Module = "database"
}
}
# Development Environment Configuration
include "root" {
path = find_in_parent_folders("root.hcl")
}
locals {
environment = "dev"
common = read_terragrunt_config(find_in_parent_folders("common.hcl"))
env_tags = {
Environment = "development"
CostCenter = "engineering"
}
}
inputs = {
environment = local.environment
vpc_cidr = local.common.locals.vpc_cidrs[local.environment]
availability_zones = local.common.locals.availability_zones
backup_retention = local.common.locals.backup_retention[local.environment]
multi_az = local.common.locals.multi_az[local.environment]
tags = merge(
local.env_tags,
{
Environment = local.environment
}
)
}
# Monitoring Configuration for Development with Datadog
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "tfr:///terraform-aws-modules/cloudwatch/aws//modules/log-group?version=4.3.0"
}
# Generate Datadog provider configuration (custom provider)
generate "datadog_provider" {
path = "datadog_provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
terraform {
required_providers {
datadog = {
source = "datadog/datadog"
version = "~> 3.30.0"
}
}
}
provider "datadog" {
api_key = var.datadog_api_key
app_key = var.datadog_app_key
}
variable "datadog_api_key" {
type = string
sensitive = true
}
variable "datadog_app_key" {
type = string
sensitive = true
}
EOF
}
# Dependencies
dependency "app" {
config_path = "../app"
mock_outputs = {
cluster_name = "myapp-dev-cluster"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
}
locals {
common = read_terragrunt_config(find_in_parent_folders("common.hcl"))
}
inputs = {
name = "/aws/ecs/${local.common.locals.name_prefix}-dev"
retention_in_days = 7
tags = {
Module = "monitoring"
Application = dependency.app.outputs.cluster_name
}
}
# Datadog monitors and dashboards would be configured here as well
# using the datadog provider resources
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
provider "aws" {
region = "us-east-1"
default_tags {
tags = { "ManagedBy" : "Terragrunt", "Repository" : "infrastructure", "Team" : "platform", "Terraform" : "true" }
}
}
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# VPC Configuration for Development
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
# Using Terraform Registry module
source = "tfr:///terraform-aws-modules/vpc/aws?version=5.1.0"
}
locals {
common = read_terragrunt_config(find_in_parent_folders("common.hcl"))
env = read_terragrunt_config(find_in_parent_folders("env.hcl"))
}
inputs = {
name = "${local.common.locals.name_prefix}-dev-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
database_subnets = ["10.0.201.0/24", "10.0.202.0/24", "10.0.203.0/24"]
enable_nat_gateway = true
single_nat_gateway = true # Cost savings for dev
enable_dns_hostnames = true
enable_dns_support = true
# VPC Flow Logs
enable_flow_log = true
create_flow_log_cloudwatch_iam_role = true
create_flow_log_cloudwatch_log_group = true
tags = {
Module = "vpc"
}
}
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
backend "s3" {
bucket = "terraform-state-123456789012"
dynamodb_table = "terraform-locks"
encrypt = true
key = "prod/terraform.tfstate"
region = "us-east-1"
}
}
# RDS Database Configuration for Production
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "tfr:///terraform-aws-modules/rds/aws?version=6.3.0"
}
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "vpc-mock-123456"
database_subnet_ids = ["subnet-mock-1", "subnet-mock-2", "subnet-mock-3"]
database_subnet_group_name = "mock-subnet-group"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
mock_outputs_merge_strategy_with_state = "shallow"
}
locals {
common = read_terragrunt_config(find_in_parent_folders("common.hcl"))
db_name = "myappdb"
}
inputs = {
identifier = "${local.common.locals.name_prefix}-prod-db"
engine = "postgres"
engine_version = "15.4"
family = "postgres15"
major_engine_version = "15"
instance_class = local.common.locals.instance_sizes["prod"]["database"]
allocated_storage = 100
max_allocated_storage = 500
storage_encrypted = true
db_name = local.db_name
username = "dbadmin"
port = 5432
db_subnet_group_name = dependency.vpc.outputs.database_subnet_group_name
vpc_security_group_ids = []
# Production settings
multi_az = true
deletion_protection = true
backup_retention_period = 30
skip_final_snapshot = false
final_snapshot_identifier = "${local.common.locals.name_prefix}-prod-db-final-snapshot"
# Performance Insights
performance_insights_enabled = true
performance_insights_retention_period = 7
enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]
create_cloudwatch_log_group = true
# Automated backups
backup_window = "03:00-06:00"
maintenance_window = "Mon:00:00-Mon:03:00"
parameters = [
{
name = "autovacuum"
value = 1
},
{
name = "client_encoding"
value = "utf8"
},
{
name = "log_min_duration_statement"
value = "1000"
}
]
tags = {
Module = "database"
Compliance = "required"
}
}
# Production Environment Configuration
include "root" {
path = find_in_parent_folders("root.hcl")
}
locals {
environment = "prod"
common = read_terragrunt_config(find_in_parent_folders("common.hcl"))
env_tags = {
Environment = "production"
CostCenter = "operations"
Compliance = "required"
}
}
inputs = {
environment = local.environment
vpc_cidr = local.common.locals.vpc_cidrs[local.environment]
availability_zones = local.common.locals.availability_zones
backup_retention = local.common.locals.backup_retention[local.environment]
multi_az = local.common.locals.multi_az[local.environment]
# Production-specific settings
enable_deletion_protection = true
enable_encryption = true
tags = merge(
local.env_tags,
{
Environment = local.environment
}
)
}
# Monitoring Configuration for Production with Datadog and New Relic
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "tfr:///terraform-aws-modules/cloudwatch/aws//modules/log-group?version=4.3.0"
}
# Generate Datadog provider configuration
generate "datadog_provider" {
path = "datadog_provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
terraform {
required_providers {
datadog = {
source = "datadog/datadog"
version = "~> 3.30.0"
}
newrelic = {
source = "newrelic/newrelic"
version = "~> 3.25.0"
}
}
}
provider "datadog" {
api_key = var.datadog_api_key
app_key = var.datadog_app_key
api_url = "https://api.datadoghq.com/"
}
provider "newrelic" {
account_id = var.newrelic_account_id
api_key = var.newrelic_api_key
region = "US"
}
variable "datadog_api_key" {
type = string
sensitive = true
}
variable "datadog_app_key" {
type = string
sensitive = true
}
variable "newrelic_account_id" {
type = string
}
variable "newrelic_api_key" {
type = string
sensitive = true
}
EOF
}
# Dependencies
dependency "database" {
config_path = "../database"
mock_outputs = {
db_instance_identifier = "myapp-prod-db"
db_instance_endpoint = "mock-db-endpoint.rds.amazonaws.com:5432"
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
}
locals {
common = read_terragrunt_config(find_in_parent_folders("common.hcl"))
}
inputs = {
name = "/aws/ecs/${local.common.locals.name_prefix}-prod"
retention_in_days = 90 # Longer retention for production
tags = {
Module = "monitoring"
Environment = "production"
Compliance = "required"
}
}
# Additional monitoring resources would be configured using datadog and newrelic providers
# Examples:
# - Datadog monitors for database metrics
# - Datadog APM configuration
# - New Relic alert policies
# - New Relic dashboards
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
provider "aws" {
region = "us-east-1"
default_tags {
tags = { "ManagedBy" : "Terragrunt", "Repository" : "infrastructure", "Team" : "platform", "Terraform" : "true" }
}
}
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# VPC Configuration for Production
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "tfr:///terraform-aws-modules/vpc/aws?version=5.1.0"
}
locals {
common = read_terragrunt_config(find_in_parent_folders("common.hcl"))
}
inputs = {
name = "${local.common.locals.name_prefix}-prod-vpc"
cidr = "10.2.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.2.1.0/24", "10.2.2.0/24", "10.2.3.0/24"]
public_subnets = ["10.2.101.0/24", "10.2.102.0/24", "10.2.103.0/24"]
database_subnets = ["10.2.201.0/24", "10.2.202.0/24", "10.2.203.0/24"]
enable_nat_gateway = true
single_nat_gateway = false # Multiple NAT gateways for HA
one_nat_gateway_per_az = true # One per AZ for production
enable_dns_hostnames = true
enable_dns_support = true
enable_flow_log = true
create_flow_log_cloudwatch_iam_role = true
create_flow_log_cloudwatch_log_group = true
tags = {
Module = "vpc"
Compliance = "required"
}
}
# Root Terragrunt Configuration
# This file contains shared configuration for all modules
# Require Terragrunt 0.93+ for new CLI features
terragrunt_version_constraint = ">= 0.93.0"
# Require Terraform/OpenTofu 1.6+
terraform_version_constraint = ">= 1.6.0"
locals {
# Automatically load account and region variables
account_id = get_env("AWS_ACCOUNT_ID", "123456789012")
aws_region = "us-east-1"
# Common tags applied to all resources
common_tags = {
Terraform = "true"
ManagedBy = "Terragrunt"
Repository = "infrastructure"
Team = "platform"
}
}
# Generate AWS provider configuration
# Note: Using "skip" to avoid conflicts with modules that have their own provider.tf
generate "provider" {
path = "provider.tf"
if_exists = "skip"
contents = <<EOF
provider "aws" {
region = "${local.aws_region}"
default_tags {
tags = ${jsonencode(local.common_tags)}
}
}
EOF
}
# Configure Terraform version and required providers
# Note: Using "skip" to avoid conflicts with registry modules that have their own versions.tf
generate "versions" {
path = "versions.tf"
if_exists = "skip"
contents = <<EOF
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
EOF
}
# Configure remote state
remote_state {
backend = "s3"
config = {
bucket = "terraform-state-${local.account_id}"
key = "${path_relative_to_include()}/terraform.tfstate"
region = local.aws_region
encrypt = true
dynamodb_table = "terraform-locks"
s3_bucket_tags = {
Name = "Terraform State Storage"
}
dynamodb_table_tags = {
Name = "Terraform Lock Table"
}
}
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
}
# Configure Terragrunt behavior
terraform {
# Log level for Terraform
extra_arguments "common_vars" {
commands = get_terraform_commands_that_need_vars()
}
# Retry on transient errors
extra_arguments "retry_lock" {
commands = get_terraform_commands_that_need_locking()
arguments = [
"-lock-timeout=5m"
]
}
}
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
backend "s3" {
bucket = "terraform-state-123456789012"
dynamodb_table = "terraform-locks"
encrypt = true
key = "staging/terraform.tfstate"
region = "us-east-1"
}
}
# Staging Environment Configuration
include "root" {
path = find_in_parent_folders("root.hcl")
}
locals {
environment = "staging"
common = read_terragrunt_config(find_in_parent_folders("common.hcl"))
env_tags = {
Environment = "staging"
CostCenter = "engineering"
}
}
inputs = {
environment = local.environment
vpc_cidr = local.common.locals.vpc_cidrs[local.environment]
availability_zones = local.common.locals.availability_zones
backup_retention = local.common.locals.backup_retention[local.environment]
multi_az = local.common.locals.multi_az[local.environment]
tags = merge(
local.env_tags,
{
Environment = local.environment
}
)
}
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
provider "aws" {
region = "us-east-1"
default_tags {
tags = { "ManagedBy" : "Terragrunt", "Repository" : "infrastructure", "Team" : "platform", "Terraform" : "true" }
}
}
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# VPC Configuration for Staging
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "tfr:///terraform-aws-modules/vpc/aws?version=5.1.0"
}
locals {
common = read_terragrunt_config(find_in_parent_folders("common.hcl"))
}
inputs = {
name = "${local.common.locals.name_prefix}-staging-vpc"
cidr = "10.1.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.1.1.0/24", "10.1.2.0/24", "10.1.3.0/24"]
public_subnets = ["10.1.101.0/24", "10.1.102.0/24", "10.1.103.0/24"]
database_subnets = ["10.1.201.0/24", "10.1.202.0/24", "10.1.203.0/24"]
enable_nat_gateway = true
single_nat_gateway = false # Multiple NAT gateways for staging
enable_dns_hostnames = true
enable_dns_support = true
enable_flow_log = true
create_flow_log_cloudwatch_iam_role = true
create_flow_log_cloudwatch_log_group = true
tags = {
Module = "vpc"
}
}
#!/usr/bin/env python3
"""Regression tests for detect_custom_resources.py."""
import json
import os
import subprocess
import tempfile
import textwrap
import unittest
from pathlib import Path
SKILL_DIR = Path(__file__).resolve().parents[1]
DETECTOR = SKILL_DIR / "scripts" / "detect_custom_resources.py"
FIXTURE_DIR = SKILL_DIR / "test" / "infrastructure"
def run_detector(target_dir: Path) -> dict:
result = subprocess.run(
["python3", str(DETECTOR), str(target_dir), "--format", "json"],
check=True,
capture_output=True,
text=True,
)
return json.loads(result.stdout)
class DetectCustomResourcesTests(unittest.TestCase):
def test_detects_expected_resources_and_ignores_cache_paths(self) -> None:
report = run_detector(FIXTURE_DIR)
providers = set(report["custom_providers"].keys())
self.assertIn("datadog/datadog", providers)
self.assertIn("newrelic/newrelic", providers)
module_sources = set(report["custom_modules"].keys())
self.assertIn("tfr:///terraform-aws-modules/vpc/aws", module_sources)
self.assertIn(
"git::https://github.com/cloudposse/terraform-aws-elasticache-redis.git",
module_sources,
)
self.assertNotIn(
"terraform-aws-modules/s3-bucket/aws",
module_sources,
"cache-derived modules should not be detected",
)
all_module_files = [
item["file"]
for modules in report["custom_modules"].values()
for item in modules
]
self.assertFalse(any(".terragrunt-cache" in path for path in all_module_files))
def test_handles_required_provider_without_version(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
fixture = Path(tmp_dir) / "main.tf"
fixture.write_text(
textwrap.dedent(
"""
terraform {
required_providers {
custom = {
source = "company/custom"
}
}
}
"""
).strip()
+ "\n",
encoding="utf-8",
)
report = run_detector(Path(tmp_dir))
self.assertIn("company/custom", report["custom_providers"])
self.assertEqual(report["custom_providers"]["company/custom"], ["unspecified"])
def test_parses_generate_blocks_with_custom_heredoc_delimiter(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
fixture = Path(tmp_dir) / "terragrunt.hcl"
fixture.write_text(
textwrap.dedent(
"""
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOT
terraform {
required_providers {
datadog = {
source = "datadog/datadog"
version = "~> 3.30.0"
}
}
}
EOT
}
"""
).strip()
+ "\n",
encoding="utf-8",
)
report = run_detector(Path(tmp_dir))
self.assertIn("datadog/datadog", report["custom_providers"])
self.assertIn("~> 3.30.0", report["custom_providers"]["datadog/datadog"])
def test_cache_directories_are_excluded(self) -> None:
"""Cache exclusion must be verified with a real .terragrunt-cache directory.
The broad fixture-level test only asserts the absence of a module that was
never present in the first place. This test creates an actual cache directory
containing a conflicting module and confirms it is not surfaced in results.
"""
with tempfile.TemporaryDirectory() as tmp_dir:
# Real unit file that should be detected.
real_dir = Path(tmp_dir) / "dev" / "vpc"
real_dir.mkdir(parents=True)
(real_dir / "terragrunt.hcl").write_text(
'terraform { source = "tfr:///terraform-aws-modules/vpc/aws?version=5.1.0" }\n',
encoding="utf-8",
)
# Simulate a .terragrunt-cache entry that should be ignored.
cache_dir = real_dir / ".terragrunt-cache" / "abc123" / "module"
cache_dir.mkdir(parents=True)
(cache_dir / "main.tf").write_text(
textwrap.dedent(
"""
module "s3_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "4.0.0"
}
"""
).strip()
+ "\n",
encoding="utf-8",
)
report = run_detector(Path(tmp_dir))
module_sources = set(report["custom_modules"].keys())
self.assertIn(
"tfr:///terraform-aws-modules/vpc/aws",
module_sources,
"real module should be detected",
)
self.assertNotIn(
"terraform-aws-modules/s3-bucket/aws",
module_sources,
"module inside .terragrunt-cache must not be surfaced",
)
def test_module_block_with_nested_object_before_source(self) -> None:
"""Source attribute must be found even when nested blocks appear first.
The old regex (non-greedy .*?) stopped at the first closing brace it saw,
so a source attribute placed after any nested object was silently dropped.
The fix uses _extract_balanced_braces, matching the approach used for
required_providers.
"""
with tempfile.TemporaryDirectory() as tmp_dir:
fixture = Path(tmp_dir) / "main.tf"
fixture.write_text(
textwrap.dedent(
"""
module "example" {
config = {
key = "value"
}
source = "my-org/example-module/aws"
version = "1.2.0"
}
"""
).strip()
+ "\n",
encoding="utf-8",
)
report = run_detector(Path(tmp_dir))
self.assertIn(
"my-org/example-module/aws",
report["custom_modules"],
"source appearing after a nested object block must still be detected",
)
detected = report["custom_modules"]["my-org/example-module/aws"]
self.assertEqual(detected[0]["version"], "1.2.0")
if __name__ == "__main__":
unittest.main()
Related skills
How it compares
Pick terragrunt-validator for Terragrunt-specific HCL, stacks, and dependency graphs; use a Terraform-only validator when no terragrunt.hcl wrappers exist.
FAQ
Which Terragrunt version does terragrunt-validator target?
terragrunt-validator targets Terragrunt 0.93+ conventions, including terragrunt run --all, terragrunt dag graph commands, and terragrunt hcl fmt --check validation. Full validation typically requires terragrunt init && terragrunt validate.
What tools does terragrunt-validator orchestrate?
terragrunt-validator orchestrates HCL formatting, terraform validate, tflint linting, Trivy and Checkov security scanning, dependency graph checks, and terragrunt plan dry-runs through validate_terragrunt.sh and helper Python scripts.